Kaynağa Gözat

ci: add pinned and audited GKI root variant builds (SSS1981-op)

* ci: add pinned GKI root variant builds

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: scope root build permissions

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix: support pinned SUSFS checkouts

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: support full root variant matrices

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: emit verified GKI artifact metadata

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: pin NoMount setup and metamodule

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: scope ABI guards around approved patches

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: resolve artifact metadata from Actions API

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: retry kernel source initialization

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: mark GKI artifacts build-verified only

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* ci: resolve NoMount commit once per run; build single metamodule

- nomount action: optional commit input (empty = latest dev tip, captured
  at run time; set = SHA-pinned and verified). Exports the hash used.
- main.yml: resolve-nomount job grabs the latest dev commit ONCE before
  the kernel matrix fans out; all seven kernel jobs integrate that same
  commit. build-nomount-module builds ONE metamodule from it and ships it
  in the release.
- build.yml: metamodule build/upload gated to the variant flow only
  (root_flavor set), so the main flow no longer emits one module per
  kernel build.

---------

Co-authored-by: SSS1981-op <282044310+SSS1981-op@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: TheWildJames <bins4us@hotmail.com>
SSS1981-op 3 hafta önce
ebeveyn
işleme
80ae41f4f6

+ 57 - 0
.github/actions/abi-kmi-safeguards/action.yml

@@ -0,0 +1,57 @@
+name: Verify ABI/KMI safeguards
+description: Capture or verify hashes of source ABI/KMI protection files
+inputs:
+  mode:
+    description: 'capture or verify'
+    required: true
+
+runs:
+  using: composite
+  steps:
+    - name: Capture or verify protected files
+      shell: bash
+      run: |
+        set -euo pipefail
+
+        root="${{ github.workspace }}/kernel"
+        manifest="${{ github.workspace }}/.abi-kmi-safeguards.sha256"
+        snapshot="$(mktemp)"
+        while IFS= read -r -d '' path; do
+          sha256sum "$path"
+        done < <(
+          find "$root" -type f \( \
+            -path '*/android/abi_gki_*' -o \
+            -path '*/build/abi/*' -o \
+            -path '*/gki/*/abi.stg' -o \
+            -path '*/gki/*/symbols/*' -o \
+            -path '*/modules.bzl' -o \
+            -path '*/BUILD.bazel' \
+          \) -print0 | sort -z
+        ) > "$snapshot"
+
+        if [ ! -s "$snapshot" ]; then
+          echo "No ABI/KMI protection files were found; refusing an unguarded build." >&2
+          exit 1
+        fi
+
+        case "${{ inputs.mode }}" in
+          capture)
+            mv "$snapshot" "$manifest"
+            ;;
+          verify)
+            if [ ! -f "$manifest" ]; then
+              echo "ABI/KMI safeguard manifest is missing." >&2
+              exit 1
+            fi
+            if ! cmp -s "$manifest" "$snapshot"; then
+              echo "ABI/KMI protection files changed during integration." >&2
+              diff -u "$manifest" "$snapshot" >&2 || true
+              exit 1
+            fi
+            rm -f "$snapshot"
+            ;;
+          *)
+            echo "Unsupported safeguard mode: ${{ inputs.mode }}" >&2
+            exit 2
+            ;;
+        esac

+ 20 - 13
.github/actions/download-kernel/action.yml

@@ -49,31 +49,35 @@ runs:
           echo "deprecated=$DEPRECATED" >> $GITHUB_OUTPUT
         }
 
-        init_repo || { echo "ERROR: init_repo failed" >&2; exit 1; }
-
         MAX_RETRIES=3
         RETRY_DELAY_SHORT=15
         SYNC_TIMEOUT="15m"
         attempt=1
 
         while [ $attempt -le $MAX_RETRIES ]; do
-          echo "Attempt $attempt/$MAX_RETRIES: repo sync (timeout $SYNC_TIMEOUT)..."
-          # Use minimal-shallow variant: current-branch, shallow init done above, reduce files downloaded
-          if timeout $SYNC_TIMEOUT repo sync -c --current-branch --no-clone-bundle --no-tags --jobs-checkout=4 -j4; then
-            echo "repo sync succeeded on attempt $attempt."
-            break
-          fi
-          rc=$?
-          if [ $rc -eq 124 ]; then
-            echo "repo sync timed out after $SYNC_TIMEOUT."
+          echo "Attempt $attempt/$MAX_RETRIES: initialize and sync kernel repository (sync timeout $SYNC_TIMEOUT)..."
+          if init_repo; then
+            # Use minimal-shallow variant: current-branch, shallow init done above, reduce files downloaded
+            if timeout $SYNC_TIMEOUT repo sync -c --current-branch --no-clone-bundle --no-tags --jobs-checkout=4 -j4; then
+              echo "Kernel repository initialization and sync succeeded on attempt $attempt."
+              break
+            else
+              rc=$?
+              if [ $rc -eq 124 ]; then
+                echo "repo sync timed out after $SYNC_TIMEOUT."
+              else
+                echo "repo sync failed with exit code $rc."
+              fi
+            fi
           else
-            echo "repo sync failed with exit code $rc."
+            rc=$?
+            echo "repo init or branch preflight failed with exit code $rc."
           fi
+
           if [ $attempt -lt $MAX_RETRIES ]; then
             echo "Cleaning workspace and retrying after ${RETRY_DELAY_SHORT}s..."
             rm -rf .repo || true
             sleep $RETRY_DELAY_SHORT
-            init_repo
           else
             echo "All $MAX_RETRIES attempts failed." >&2
             exit $rc
@@ -138,6 +142,9 @@ runs:
 
         COMMIT_DATE=$(git log -1 --format=%cI)
         COMMIT_MSG=$(git log -1 --format=%s)
+        COMMIT_SHA=$(git rev-parse HEAD)
 
+        echo "KERNEL_SOURCE_COMMIT=$COMMIT_SHA" >> "$GITHUB_ENV"
         echo "Kernel common commit date: $COMMIT_DATE"
         echo "Kernel common commit msg: $COMMIT_MSG"
+        echo "Kernel common commit SHA: $COMMIT_SHA"

+ 71 - 0
.github/actions/nomount-metamodule/action.yml

@@ -0,0 +1,71 @@
+name: Build pinned NoMount metamodule
+description: Package a flashable NoMount metamodule from the exact kernel integration SHA
+inputs:
+  commit:
+    description: 'Full immutable NoMount commit SHA'
+    required: true
+outputs:
+  module_path:
+    description: 'Path to the flashable metamodule ZIP'
+    value: ${{ steps.package.outputs.module_path }}
+  commit:
+    description: 'Verified NoMount source SHA'
+    value: ${{ steps.package.outputs.commit }}
+
+runs:
+  using: composite
+  steps:
+    - name: Set up Zig
+      uses: jetsung/setup-zig@v0.0.4
+      with:
+        version: master
+
+    - name: Build and package matching metamodule
+      id: package
+      shell: bash
+      run: |
+        set -euo pipefail
+
+        expected_commit="${{ inputs.commit }}"
+        if ! [[ "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then
+          echo "NoMount commit must be a full 40-character SHA." >&2
+          exit 2
+        fi
+
+        source_dir="${RUNNER_TEMP}/nomount-module-source"
+        module_dir="${RUNNER_TEMP}/nomount-metamodule"
+        module_zip="${RUNNER_TEMP}/NoMount-${expected_commit}.zip"
+        git clone --no-checkout https://github.com/maxsteeel/nomount.git "$source_dir"
+        git -C "$source_dir" fetch --depth=1 origin "$expected_commit"
+        git -C "$source_dir" checkout --detach "$expected_commit"
+        actual_commit="$(git -C "$source_dir" rev-parse HEAD)"
+        if [ "$actual_commit" != "$expected_commit" ]; then
+          echo "NoMount module source mismatch: expected $expected_commit, got $actual_commit." >&2
+          exit 1
+        fi
+
+        mkdir -p "$source_dir/bin"
+        (
+          cd "$source_dir/userspace/src"
+          zig cc -target aarch64-linux -Oz -mcmodel=tiny -static -nostdlib \
+            -ffreestanding -fno-unwind-tables -fno-ident -Wno-invalid-noreturn \
+            -Wl,--entry=_start nm.c -o ../../bin/nm-arm64
+          zig cc -target arm-linux -Oz -static -nostdlib -ffreestanding \
+            -fno-unwind-tables -fno-ident -Wno-invalid-noreturn \
+            -ffunction-sections -fdata-sections -Wl,--gc-sections \
+            -Wl,--entry=_start nm.c -o ../../bin/nm-arm
+        )
+
+        cp -a "$source_dir/module" "$module_dir"
+        mkdir -p "$module_dir/bin"
+        install -m 0755 "$source_dir/bin/nm-arm64" "$module_dir/bin/nm-arm64"
+        install -m 0755 "$source_dir/bin/nm-arm" "$module_dir/bin/nm-arm"
+        sed -i "s/^versionCode=.*/versionCode=1/" "$module_dir/module.prop"
+        printf '%s\n' "$actual_commit" > "$module_dir/NOMOUNT_SOURCE_COMMIT"
+        (
+          cd "$module_dir"
+          zip -qr "$module_zip" .
+        )
+        sha256sum "$module_zip" > "${module_zip}.sha256"
+        echo "module_path=$module_zip" >> "$GITHUB_OUTPUT"
+        echo "commit=$actual_commit" >> "$GITHUB_OUTPUT"

+ 53 - 8
.github/actions/nomount/action.yml

@@ -1,16 +1,28 @@
 name: 'Setup NoMount'
-description: 'Integrate NoMount VFS hooks and enable CONFIG_NOMOUNT'
+description: 'Integrate NoMount VFS hooks (pinned SHA or latest dev) and enable CONFIG_NOMOUNT'
 
 inputs:
+  commit:
+    description: 'Full immutable NoMount commit SHA (empty = latest dev branch tip)'
+    required: false
+    default: ''
   kernel_version:
     description: 'Kernel major/minor version (for example, 5.10 or 6.6)'
-    required: true
+    required: false
+    default: ''
+
+outputs:
+  commit:
+    description: 'NoMount commit SHA actually integrated'
+    value: ${{ steps.integrate.outputs.commit }}
 
 runs:
   using: composite
   steps:
-    - name: Fetch and integrate NoMount (dev method)
+    - name: Integrate NoMount
+      id: integrate
       shell: bash
+      working-directory: ${{ github.workspace }}/kernel
       env:
         KERNEL_VERSION: ${{ inputs.kernel_version }}
       run: |
@@ -22,15 +34,48 @@ runs:
           exit 1
         }
 
-        cd "$KERNEL_DIR"
-        curl -fsSL https://raw.githubusercontent.com/maxsteeel/nomount/refs/heads/dev/kernel/setup.sh | bash -s dev
+        requested_commit="${{ inputs.commit }}"
+
+        if [ -z "$requested_commit" ]; then
+          # Float mode: latest dev branch tip, captured at run time
+          echo "NoMount: no commit pinned, using latest dev branch tip"
+          (
+            cd "$KERNEL_DIR"
+            curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+              https://raw.githubusercontent.com/maxsteeel/nomount/refs/heads/dev/kernel/setup.sh | bash -s dev
+          )
+          actual_commit="$(git -C "$KERNEL_DIR/NoMount" rev-parse HEAD)"
+        else
+          # Pin mode: fetch the setup script at the exact SHA and verify it landed
+          if ! [[ "$requested_commit" =~ ^[0-9a-f]{40}$ ]]; then
+            echo "NoMount commit must be a full 40-character SHA." >&2
+            exit 2
+          fi
+          setup_script="${RUNNER_TEMP}/nomount-setup-${requested_commit}.sh"
+          curl --fail --location --silent --show-error --retry 5 --retry-delay 5 --retry-all-errors \
+            "https://raw.githubusercontent.com/maxsteeel/nomount/${requested_commit}/kernel/setup.sh" \
+            --output "$setup_script"
+          test -s "$setup_script"
+          chmod 0755 "$setup_script"
+          (
+            cd "$KERNEL_DIR"
+            "$setup_script" "$requested_commit"
+          )
+          actual_commit="$(git -C "$KERNEL_DIR/NoMount" rev-parse HEAD)"
+          if [ "$actual_commit" != "$requested_commit" ]; then
+            echo "NoMount commit mismatch: expected $requested_commit, got $actual_commit." >&2
+            exit 1
+          fi
+        fi
 
-        test -L "fs/nomount" || {
+        if [ ! -L "$KERNEL_DIR/fs/nomount" ]; then
           echo "NoMount integration failed: fs/nomount symlink missing" >&2
           exit 1
-        }
+        fi
 
-        echo "NoMount integrated (dev method, kernel $KERNEL_VERSION)"
+        echo "commit=$actual_commit" >> "$GITHUB_OUTPUT"
+        echo "NOMOUNT_COMMIT=$actual_commit" >> "$GITHUB_ENV"
+        echo "NoMount integrated (kernel $KERNEL_VERSION, commit $actual_commit)"
 
     - name: Enable NoMount in kernel defconfig
       uses: ./.github/actions/set-kernel-config

+ 104 - 0
.github/actions/root-setup/action.yml

@@ -0,0 +1,104 @@
+name: Setup pinned root implementation
+description: Clone exactly one supported root implementation at a verified commit
+inputs:
+  flavor:
+    description: 'classic, next, or resukisu'
+    required: true
+  commit:
+    description: 'Full immutable commit SHA'
+    required: true
+
+runs:
+  using: composite
+  steps:
+    - name: Clone and integrate selected root
+      shell: bash
+      working-directory: ${{ github.workspace }}/kernel
+      run: |
+        set -euo pipefail
+
+        flavor="${{ inputs.flavor }}"
+        expected_commit="${{ inputs.commit }}"
+        case "$flavor" in
+          classic)
+            repo="https://github.com/tiann/KernelSU.git"
+            directory="KernelSU"
+            manager="KernelSU Manager"
+            ;;
+          next)
+            repo="https://github.com/KernelSU-Next/KernelSU-Next.git"
+            directory="KernelSU-Next"
+            manager="KernelSU Next Manager"
+            ;;
+          resukisu)
+            repo="https://github.com/ReSukiSU/ReSukiSU.git"
+            directory="ReSukiSU"
+            manager="ReSukiSU Manager"
+            ;;
+          *)
+            echo "Unsupported root flavor: $flavor" >&2
+            exit 2
+            ;;
+        esac
+
+        if ! [[ "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then
+          echo "Root commit must be a full 40-character SHA." >&2
+          exit 2
+        fi
+
+        for candidate in KernelSU KernelSU-Next ReSukiSU; do
+          if [ "$candidate" != "$directory" ] && [ -e "$candidate" ]; then
+            echo "Refusing mixed root implementations: found $candidate." >&2
+            exit 1
+          fi
+        done
+
+        if [ -d common/drivers ]; then
+          drivers_dir="common/drivers"
+        elif [ -d drivers ]; then
+          drivers_dir="drivers"
+        else
+          echo "Kernel drivers directory not found." >&2
+          exit 1
+        fi
+
+        if [ -e "$drivers_dir/kernelsu" ] || [ -L "$drivers_dir/kernelsu" ]; then
+          echo "Refusing to replace an existing kernelsu integration." >&2
+          exit 1
+        fi
+        if [ -e "$directory" ]; then
+          echo "Refusing to reuse an existing root checkout: $directory." >&2
+          exit 1
+        fi
+
+        git clone --no-checkout "$repo" "$directory"
+        git -C "$directory" fetch --depth=1 origin "$expected_commit"
+        git -C "$directory" checkout --detach "$expected_commit"
+        actual_commit="$(git -C "$directory" rev-parse HEAD)"
+        if [ "$actual_commit" != "$expected_commit" ]; then
+          echo "Root commit mismatch: expected $expected_commit, got $actual_commit." >&2
+          exit 1
+        fi
+        if [ ! -f "$directory/kernel/Kconfig" ] || [ ! -f "$directory/kernel/Makefile" ]; then
+          echo "Selected root checkout does not contain a kernel integration." >&2
+          exit 1
+        fi
+        root_version="$(git -C "$directory" describe --tags --always --dirty)"
+
+        relative_kernel="$(realpath --relative-to="$drivers_dir" "$PWD/$directory/kernel")"
+        ln -s "$relative_kernel" "$drivers_dir/kernelsu"
+        grep -q 'obj-$(CONFIG_KSU) += kernelsu/' "$drivers_dir/Makefile" ||
+          printf '\nobj-$(CONFIG_KSU) += kernelsu/\n' >> "$drivers_dir/Makefile"
+        grep -q 'source "drivers/kernelsu/Kconfig"' "$drivers_dir/Kconfig" ||
+          sed -i '/endmenu/i source "drivers/kernelsu/Kconfig"' "$drivers_dir/Kconfig"
+
+        echo "ROOT_IMPLEMENTATION=$flavor" >> "$GITHUB_ENV"
+        echo "ROOT_MANAGER=$manager" >> "$GITHUB_ENV"
+        echo "ROOT_VERSION=$root_version" >> "$GITHUB_ENV"
+        echo "ROOT_COMMIT=$actual_commit" >> "$GITHUB_ENV"
+
+    - name: Enable root configuration
+      uses: ./.github/actions/set-kernel-config
+      with:
+        config_list: |
+          CONFIG_KSU=y

+ 5 - 4
.github/actions/susfs-setup/action.yml

@@ -38,12 +38,13 @@ runs:
 
         if [ -n "${{ inputs.susfs_commit }}" ]; then
           echo "Checking out SUSFS commit: ${{ inputs.susfs_commit }}"
-          cd ${{ github.workspace }}/susfs4ksu
-          retry git checkout "${{ inputs.susfs_commit }}"
+          retry git -C "${{ github.workspace }}/susfs4ksu" checkout "${{ inputs.susfs_commit }}"
         fi
 
-        cd susfs4ksu
+        cd "${{ github.workspace }}/susfs4ksu"
+        SUSFS_COMMIT="$(git rev-parse HEAD)"
+        echo "SUSFS_COMMIT=$SUSFS_COMMIT" >> "$GITHUB_ENV"
+        echo "Using SUSFS commit: $SUSFS_COMMIT"
 
         patch -p1 < "${{ github.workspace }}/kernel_patches/pershoot/susfs4ksu/0001-pershoot-Allow-core-to-be-built-with-no-features.patch"
         patch -p1 < "${{ github.workspace }}/kernel_patches/pershoot/susfs4ksu/0002-pershoot-Implement-SuSFS-and-Toolkit-coexistence.patch"
-

+ 206 - 2
.github/workflows/build.yml

@@ -1,6 +1,6 @@
 name: Kernel Build Process
 permissions:
-  contents: write
+  contents: read
   actions: write
 
 on:
@@ -35,6 +35,26 @@ on:
         required: false
         type: string
         default: ""
+      root_flavor:
+        required: false
+        type: string
+        default: ""
+      root_commit:
+        required: false
+        type: string
+        default: ""
+      nomount_commit:
+        required: false
+        type: string
+        default: ""
+      preserve_abi:
+        required: false
+        type: boolean
+        default: false
+      build_bypass:
+        required: false
+        type: boolean
+        default: true
 
 jobs:
   build-gki:
@@ -141,12 +161,31 @@ jobs:
         sublevel: ${{ steps.extract.outputs.sublevel }}
         os_patch_level: ${{ inputs.os_patch_level }}
 
+    - name: Capture source ABI/KMI safeguards
+      if: ${{ inputs.preserve_abi }}
+      uses: ./.github/actions/abi-kmi-safeguards
+      with:
+        mode: capture
+
+    - name: Setup Selected Root Implementation
+      if: ${{ inputs.root_flavor != '' }}
+      uses: ./.github/actions/root-setup
+      with:
+        flavor: ${{ inputs.root_flavor }}
+        commit: ${{ inputs.root_commit }}
+
     - name: Setup KernelSU-Next
-      if: contains(inputs.feature_set, 'KSUN') || inputs.feature_set == 'FULL'
+      if: ${{ inputs.root_flavor == '' && (contains(inputs.feature_set, 'KSUN') || inputs.feature_set == 'FULL') }}
       uses: ./.github/actions/kernelsu
       with:
         ksu_branch: ${{ inputs.ksu_branch }}
 
+    - name: Verify root ABI/KMI safeguards
+      if: ${{ inputs.preserve_abi }}
+      uses: ./.github/actions/abi-kmi-safeguards
+      with:
+        mode: verify
+
     - name: SUSFS
       if: contains(inputs.feature_set, 'SUSFS') || inputs.feature_set == 'FULL'
       uses: ./.github/actions/susfs
@@ -223,6 +262,26 @@ jobs:
         version: ${{ inputs.version }}
         kernel_version: ${{ inputs.kernel_version }}
 
+    - name: Capture approved feature ABI/KMI baseline
+      if: ${{ inputs.preserve_abi }}
+      uses: ./.github/actions/abi-kmi-safeguards
+      with:
+        mode: capture
+
+    - name: Integrate NoMount
+      if: ${{ inputs.nomount_commit != '' || contains(inputs.feature_set, 'NOMOUNT') || inputs.feature_set == 'FULL' }}
+      uses: ./.github/actions/nomount
+      with:
+        commit: ${{ inputs.nomount_commit }}
+        kernel_version: ${{ inputs.kernel_version }}
+
+    - name: Build matching NoMount metamodule
+      id: nomount-metamodule
+      if: ${{ inputs.root_flavor != '' && inputs.nomount_commit != '' }}
+      uses: ./.github/actions/nomount-metamodule
+      with:
+        commit: ${{ inputs.nomount_commit }}
+
     - name: Apply ABI Compare Bypass
       if: false
       working-directory: ${{ github.workspace }}/kernel
@@ -253,8 +312,15 @@ jobs:
         sublevel: ${{ steps.extract.outputs.sublevel }}
 
     - name: Remove Protected Exports
+      if: ${{ !inputs.preserve_abi }}
       uses: ./.github/actions/remove-protected-exports
 
+    - name: Verify NoMount ABI/KMI safeguards
+      if: ${{ inputs.preserve_abi }}
+      uses: ./.github/actions/abi-kmi-safeguards
+      with:
+        mode: verify
+
     - name: Clean Kernel Flags
       uses: ./.github/actions/clean-kernel-flags
 
@@ -387,6 +453,7 @@ jobs:
         github_token: ${{ secrets.MY_GITHUB_TOKEN }}
 
     - name: Build Bypass Kernel
+      if: ${{ inputs.build_bypass }}
       env:
         SOURCE_DATE_EPOCH: ${{ env.SOURCE_DATE_EPOCH }}
         KBUILD_BUILD_TIMESTAMP: ${{ env.KBUILD_BUILD_TIMESTAMP }}
@@ -419,6 +486,12 @@ jobs:
         # Get KSU version from env (set by kernelsu action)
         KSU_VER="${KSU_VERSION:-N/A}"
         KSU_TAG="${KSU_GIT_TAG:-N/A}"
+        ROOT_IMPL="${ROOT_IMPLEMENTATION:-KernelSU-Next (legacy path)}"
+        ROOT_MANAGER="${ROOT_MANAGER:-KernelSU-Next Manager}"
+        ROOT_VERSION="${ROOT_VERSION:-${KSU_TAG}}"
+        ROOT_SHA="${ROOT_COMMIT:-${KSU_COMMIT:-N/A}}"
+        NOMOUNT_SHA="${NOMOUNT_COMMIT:-N/A}"
+        SUSFS_SHA="${SUSFS_COMMIT:-N/A}"
         
         # Get SUSFS version from susfs.h if available
         SUSFS_VER="N/A"
@@ -447,7 +520,13 @@ jobs:
           fi
           echo "| **KSU Version** | ${KSU_VER} |"
           echo "| **KSU Tag** | ${KSU_TAG} |"
+          echo "| **Root Implementation** | ${ROOT_IMPL} |"
+          echo "| **Root Manager** | ${ROOT_MANAGER} |"
+          echo "| **Root Version** | ${ROOT_VERSION} |"
+          echo "| **Root Commit** | ${ROOT_SHA} |"
           echo "| **SUSFS Version** | ${SUSFS_VER} |"
+          echo "| **SUSFS Commit** | ${SUSFS_SHA} |"
+          echo "| **NoMount Commit** | ${NOMOUNT_SHA} |"
           echo "| **Feature Set** | ${{ inputs.feature_set }} |"
           echo "| **Variant** | ${{ inputs.variant }} |"
           echo
@@ -470,9 +549,134 @@ jobs:
         compression-level: 9
 
     - name: Upload Artifacts
+      id: kernel-artifact
       uses: actions/upload-artifact@v7
       with:
         name: ${{ steps.extract.outputs.file_name }}-AnyKernel3
         path: ${{ github.workspace }}/AnyKernel3/**
         if-no-files-found: ignore
         compression-level: 9
+
+    - name: Upload NoMount metamodule
+      id: nomount-metamodule-artifact
+      if: ${{ inputs.root_flavor != '' && inputs.nomount_commit != '' }}
+      uses: actions/upload-artifact@v7
+      with:
+        name: ${{ steps.extract.outputs.file_name }}-NoMount-Metamodule
+        path: ${{ steps.nomount-metamodule.outputs.module_path }}
+        if-no-files-found: error
+        compression-level: 0
+
+    - name: Write Build-verified only artifact metadata
+      if: ${{ success() && inputs.root_flavor != '' && inputs.nomount_commit != '' }}
+      shell: bash
+      env:
+        GH_TOKEN: ${{ github.token }}
+      run: |
+        set -euo pipefail
+
+        metadata_file="${{ github.workspace }}/${{ steps.extract.outputs.file_name }}-metadata.json"
+        artifacts_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100"
+        artifacts_json="$(curl --fail --silent --show-error \
+          --header "Authorization: Bearer ${GH_TOKEN}" \
+          --header "X-GitHub-Api-Version: 2022-11-28" \
+          "$artifacts_api")"
+
+        resolve_artifact() {
+          local artifact_name="$1"
+          local matches
+          matches="$(jq --arg name "$artifact_name" \
+            '[.artifacts[] | select(.name == $name and .expired == false)]' \
+            <<<"$artifacts_json")"
+          if [ "$(jq 'length' <<<"$matches")" -ne 1 ]; then
+            echo "Expected exactly one available artifact named ${artifact_name}." >&2
+            exit 1
+          fi
+          jq -r '.[0] | [.archive_download_url, .digest] | @tsv' <<<"$matches"
+        }
+
+        read -r kernel_artifact_url kernel_artifact_sha256 < <(
+          resolve_artifact "${{ steps.extract.outputs.file_name }}-AnyKernel3"
+        )
+        read -r nomount_module_url nomount_module_sha256 < <(
+          resolve_artifact "${{ steps.extract.outputs.file_name }}-NoMount-Metamodule"
+        )
+
+        jq -n \
+          --arg method "GKI source build with SHA-pinned root implementation, SUSFS, and NoMount" \
+          --arg root_implementation "${ROOT_IMPLEMENTATION:-KernelSU-Next (legacy path)}" \
+          --arg root_manager "${ROOT_MANAGER:-KernelSU-Next Manager}" \
+          --arg root_version "${ROOT_VERSION:-${KSU_GIT_TAG:-N/A}}" \
+          --arg root_commit "${ROOT_COMMIT:-${KSU_COMMIT:-N/A}}" \
+          --arg susfs_commit "${SUSFS_COMMIT:-N/A}" \
+          --arg nomount_commit "${NOMOUNT_COMMIT:-N/A}" \
+          --arg android_branch "${{ inputs.android_version }}-${{ inputs.kernel_version }}-${{ inputs.os_patch_level }}" \
+          --arg kmi "${{ inputs.android_version }}-${{ inputs.kernel_version }}" \
+          --arg kernel_source_commit "${KERNEL_SOURCE_COMMIT:-N/A}" \
+          --arg provenance_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
+          --arg artifact_url "$kernel_artifact_url" \
+          --arg sha256 "$kernel_artifact_sha256" \
+          --arg nomount_module_commit "${{ steps.nomount-metamodule.outputs.commit }}" \
+          --arg nomount_module_url "$nomount_module_url" \
+          --arg nomount_module_sha256 "$nomount_module_sha256" \
+          --arg status "Build-verified only" \
+          --arg catalog_availability "eligible-with-provenance-and-checksums" \
+          --arg device_compatibility "not-validated" \
+          --arg flashability "not-guaranteed" \
+          --arg boot "not-guaranteed" \
+          '{
+            method: $method,
+            root: {
+              implementation: $root_implementation,
+              manager: $root_manager,
+              version: $root_version,
+              commit: $root_commit
+            },
+            susfs_revision: $susfs_commit,
+            nomount_commit: $nomount_commit,
+            android_branch: $android_branch,
+            kmi: $kmi,
+            kernel_source_commit: $kernel_source_commit,
+            provenance_url: $provenance_url,
+            artifact_url: $artifact_url,
+            sha256: $sha256,
+            nomount_metamodule: {
+              commit: $nomount_module_commit,
+              artifact_url: $nomount_module_url,
+              sha256: $nomount_module_sha256
+            },
+            catalog: {
+              availability: $catalog_availability,
+              device_compatibility: $device_compatibility,
+              flashability: $flashability,
+              boot: $boot
+            },
+            status: $status
+          }' > "$metadata_file"
+
+        jq -e '
+          .status == "Build-verified only" and
+          (.root.manager | length > 0) and
+          (.root.version | length > 0) and
+          (.susfs_revision | test("^[0-9a-f]{40}$")) and
+          (.nomount_commit | test("^[0-9a-f]{40}$")) and
+          (.kernel_source_commit | test("^[0-9a-f]{40}$")) and
+          (.artifact_url | startswith("https://")) and
+          (.sha256 | test("^sha256:[0-9a-f]{64}$")) and
+          (.nomount_metamodule.commit == .nomount_commit) and
+          (.nomount_metamodule.artifact_url | startswith("https://")) and
+          (.nomount_metamodule.sha256 | test("^sha256:[0-9a-f]{64}$")) and
+          .catalog.availability == "eligible-with-provenance-and-checksums" and
+          .catalog.device_compatibility == "not-validated" and
+          .catalog.flashability == "not-guaranteed" and
+          .catalog.boot == "not-guaranteed"
+        ' "$metadata_file" > /dev/null
+
+    - name: Upload Build-verified only artifact metadata
+      if: ${{ success() && inputs.root_flavor != '' && inputs.nomount_commit != '' }}
+      uses: actions/upload-artifact@v7
+      with:
+        name: ${{ steps.extract.outputs.file_name }}-Metadata
+        path: ${{ github.workspace }}/${{ steps.extract.outputs.file_name }}-metadata.json
+        if-no-files-found: error
+        compression-level: 9

+ 79 - 7
.github/workflows/main.yml

@@ -122,8 +122,52 @@ jobs:
           retention-days: 1
           if-no-files-found: warn
 
+  resolve-nomount:
+    runs-on: ubuntu-latest
+    outputs:
+      nomount_commit: ${{ steps.resolve.outputs.nomount_commit }}
+    steps:
+      - name: Resolve latest NoMount dev commit
+        id: resolve
+        if: ${{ contains(inputs.feature_set, 'NOMOUNT') || inputs.feature_set == 'FULL' }}
+        shell: bash
+        run: |
+          set -euo pipefail
+          sha="$(git ls-remote https://github.com/maxsteeel/nomount.git refs/heads/dev | awk '{print $1}')"
+          if [ -z "$sha" ] || ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then
+            echo "Failed to resolve NoMount dev branch tip." >&2
+            exit 1
+          fi
+          echo "nomount_commit=$sha" >> "$GITHUB_OUTPUT"
+          echo "Resolved NoMount dev commit: $sha"
+
+  build-nomount-module:
+    needs: resolve-nomount
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+        if: ${{ contains(inputs.feature_set, 'NOMOUNT') || inputs.feature_set == 'FULL' }}
+
+      - name: Build NoMount metamodule
+        id: nomount-metamodule
+        if: ${{ contains(inputs.feature_set, 'NOMOUNT') || inputs.feature_set == 'FULL' }}
+        uses: ./.github/actions/nomount-metamodule
+        with:
+          commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
+
+      - name: Upload NoMount metamodule
+        if: ${{ contains(inputs.feature_set, 'NOMOUNT') || inputs.feature_set == 'FULL' }}
+        uses: actions/upload-artifact@v7
+        with:
+          name: NoMount-Metamodule
+          path: ${{ steps.nomount-metamodule.outputs.module_path }}
+          if-no-files-found: error
+          compression-level: 0
+
   build-android12-5-10:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android12-5.10') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -131,10 +175,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android12-5-10 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android13-5-10:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android13-5.10') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -142,10 +189,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android13-5-10 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android13-5-15:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android13-5.15') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -153,10 +203,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android13-5-15 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android14-5-15:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android14-5.15') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -164,10 +217,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android14-5-15 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android14-6-1:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android14-6.1') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -175,10 +231,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android14-6-1 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android15-6-6:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android15-6.6') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -186,10 +245,13 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android15-6-6 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   build-android16-6-12:
-    needs: prepare-ccache
+    needs:
+    - prepare-ccache
+    - resolve-nomount
     if: ${{ !inputs.test_release_notes && (inputs.kernel_build_version == 'All' || inputs.kernel_build_version == 'android16-6.12') }}
     uses: ./.github/workflows/prepare.yml
     with:
@@ -197,6 +259,7 @@ jobs:
       feature_set: ${{ inputs.feature_set || 'FULL' }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit_android16-6-12 }}
+      nomount_commit: ${{ needs.resolve-nomount.outputs.nomount_commit }}
     secrets: inherit
 
   rej:
@@ -390,6 +453,7 @@ jobs:
     - build-android14-6-1
     - build-android15-6-6
     - build-android16-6-12
+    - build-nomount-module
 
     env:
       GH_TOKEN: ${{ github.token }}
@@ -595,6 +659,14 @@ jobs:
         pattern: '*-AnyKernel3'
         merge-multiple: false
 
+    - name: Download NoMount metamodule
+      uses: actions/download-artifact@v7
+      with:
+        path: release-assets
+        pattern: '*NoMount-Metamodule'
+        merge-multiple: false
+        if-no-files-found: ignore
+
     - name: Zip AnyKernel3 for Release
       run: |
         set -e

+ 75 - 17
.github/workflows/prepare.yml

@@ -21,6 +21,41 @@ on:
         required: false
         type: string
         default: ""
+      root_flavor:
+        description: "Mutually exclusive root implementation (classic, next, or resukisu)"
+        required: false
+        type: string
+        default: ""
+      root_commit:
+        description: "Exact root implementation commit"
+        required: false
+        type: string
+        default: ""
+      nomount_commit:
+        description: "Exact NoMount commit"
+        required: false
+        type: string
+        default: ""
+      preserve_abi:
+        description: "Keep ABI/KMI protection files intact and verify their hashes"
+        required: false
+        type: boolean
+        default: false
+      build_bypass:
+        description: "Produce the legacy bypass image"
+        required: false
+        type: boolean
+        default: true
+      os_patch_level:
+        description: "Optional patch level filter; use latest for the latest dated target"
+        required: false
+        type: string
+        default: ""
+      variant:
+        description: "Artifact variant label"
+        required: false
+        type: string
+        default: ""
 
 jobs:
   generate-matrix:
@@ -42,40 +77,58 @@ jobs:
 
           CONFIG_JSON=$(cat "${{ inputs.config_file }}" | jq -c .)
 
-          # Build { "include": [ {android_version:..., kernel_version:..., sublevel:..., os_patch_level:..., variant: ...}, ... ] }
-          # Always include Normal, and add any explicit per-item variant from the config.
+          # Build { "include": [ {android_version:..., kernel_version:..., sublevel:..., os_patch_level:..., variant: ...}, ... ] }.
+          # An explicit patch-level filter keeps manual validation dispatches bounded.
           FINAL=$(jq -cn \
-            --argjson cfg "$CONFIG_JSON" '
+            --argjson cfg "$CONFIG_JSON" \
+            --arg requested_variant "${{ inputs.variant }}" '
             ($cfg.version | split("-")) as $version_parts
+            | ($cfg.include) as $targets
             | {
               include: (
                 [
-                  $cfg.include[] as $item
+                  $targets[] as $item
                   | {
                       version: $cfg.version,
                       android_version: $version_parts[0],
                       kernel_version: $version_parts[1],
                       sublevel: $item.sublevel,
                       os_patch_level: $item.date,
-                      variant: "Normal"
-                    }
-                ]
-                + [
-                  $cfg.include[] as $item
-                  | select($item.variant != null)
-                  | {
-                      version: $cfg.version,
-                      android_version: $version_parts[0],
-                      kernel_version: $version_parts[1],
-                      sublevel: $item.sublevel,
-                      os_patch_level: $item.date,
-                      variant: $item.variant
+                      variant: (if $requested_variant == "" then "Normal" else $requested_variant end)
                     }
                 ]
+                + (if $requested_variant == "" then [
+                    $targets[] as $item
+                    | select($item.variant != null)
+                    | {
+                        version: $cfg.version,
+                        android_version: $version_parts[0],
+                        kernel_version: $version_parts[1],
+                        sublevel: $item.sublevel,
+                        os_patch_level: $item.date,
+                        variant: $item.variant
+                      }
+                  ] else [] end)
                 | unique_by([.android_version, .kernel_version, .sublevel, .os_patch_level, .variant])
               )
             }')
 
+          SELECTED_PATCH_LEVEL="${{ inputs.os_patch_level }}"
+          if [ "$SELECTED_PATCH_LEVEL" = "all" ]; then
+            SELECTED_PATCH_LEVEL=""
+          elif [ "$SELECTED_PATCH_LEVEL" = "latest" ]; then
+            SELECTED_PATCH_LEVEL=$(jq -r '[.include[] | select(.date != "lts") | .date] | last // empty' "${{ inputs.config_file }}")
+          fi
+          if [ -n "$SELECTED_PATCH_LEVEL" ]; then
+            FINAL=$(jq -c --arg patch_level "$SELECTED_PATCH_LEVEL" '
+              {include: [.include[] | select(.os_patch_level == $patch_level)]}
+            ' <<< "$FINAL")
+          fi
+          if [ "$(jq '.include | length' <<< "$FINAL")" -eq 0 ]; then
+            echo "Error: no build targets match patch level '${SELECTED_PATCH_LEVEL}'." >&2
+            exit 1
+          fi
+
           echo "matrix=$FINAL" >> "$GITHUB_OUTPUT"
 
   build:
@@ -94,4 +147,9 @@ jobs:
       feature_set: ${{ inputs.feature_set }}
       ksu_branch: ${{ inputs.ksu_branch }}
       susfs_commit: ${{ inputs.susfs_commit }}
+      root_flavor: ${{ inputs.root_flavor }}
+      root_commit: ${{ inputs.root_commit }}
+      nomount_commit: ${{ inputs.nomount_commit }}
+      preserve_abi: ${{ inputs.preserve_abi }}
+      build_bypass: ${{ inputs.build_bypass }}
     secrets: inherit

+ 80 - 0
.github/workflows/root-variants.yml

@@ -0,0 +1,80 @@
+name: Build-verified only GKI root variants
+
+on:
+  workflow_dispatch:
+    inputs:
+      kernel_build_version:
+        description: "Supported GKI target"
+        required: true
+        type: choice
+        options:
+          - android12-5.10
+          - android13-5.10
+          - android13-5.15
+          - android14-5.15
+          - android14-6.1
+          - android15-6.6
+          - android16-6.12
+        default: android14-6.1
+      os_patch_level:
+        description: "Patch level in the selected target config, latest, or all"
+        required: true
+        type: string
+        default: latest
+
+permissions:
+  contents: read
+  actions: write
+
+jobs:
+  resolve-pins:
+    runs-on: ubuntu-latest
+    outputs:
+      config_file: ${{ steps.pins.outputs.config_file }}
+      susfs_commit: ${{ steps.pins.outputs.susfs_commit }}
+    steps:
+      - id: pins
+        shell: bash
+        run: |
+          set -euo pipefail
+          case "${{ inputs.kernel_build_version }}" in
+            android12-5.10) susfs_commit="3c14ad549f826b1f53878ec8c12253efebeed75a" ;;
+            android13-5.10) susfs_commit="f81aaf10e9560282052bb61dd931315c2ca3e617" ;;
+            android13-5.15) susfs_commit="ccb1918684b27644d17a6c842f57b60ae5966025" ;;
+            android14-5.15) susfs_commit="0463ac089308014e8c22cc6a4558e0d6d2a53e08" ;;
+            android14-6.1) susfs_commit="e287d59066380bf6de4396532d4a42edf4408701" ;;
+            android15-6.6) susfs_commit="be7b7ef49a1e1b189c3abf00eacaa7ebdb4168c1" ;;
+            android16-6.12) susfs_commit="f37930f374ef88de990d6abea0c67d0ea28c1edc" ;;
+            *) echo "Unsupported GKI target." >&2; exit 2 ;;
+          esac
+          echo "config_file=.github/config/${{ inputs.kernel_build_version }}.json" >> "$GITHUB_OUTPUT"
+          echo "susfs_commit=$susfs_commit" >> "$GITHUB_OUTPUT"
+
+  build:
+    needs: resolve-pins
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - flavor: classic
+            commit: da9abf498a77d438989fea0f5f4e348b9a540c07
+            variant: Classic-KernelSU
+          - flavor: next
+            commit: 234f6e040fcbca18b16d2398e1aa225712ec99ad
+            variant: KernelSU-Next
+          - flavor: resukisu
+            commit: 3ef06b0fcb0960dc9563256fe26a58e892663387
+            variant: ReSukiSU
+    uses: ./.github/workflows/prepare.yml
+    with:
+      config_file: ${{ needs.resolve-pins.outputs.config_file }}
+      feature_set: SUSFS
+      susfs_commit: ${{ needs.resolve-pins.outputs.susfs_commit }}
+      root_flavor: ${{ matrix.flavor }}
+      root_commit: ${{ matrix.commit }}
+      nomount_commit: c52936b229c25a4b0e41b6627f7d3bc5eaaaf2b5
+      preserve_abi: true
+      build_bypass: false
+      os_patch_level: ${{ inputs.os_patch_level }}
+      variant: ${{ matrix.variant }}
+    secrets: inherit

+ 76 - 0
docs/ROOT_VARIANTS.md

@@ -0,0 +1,76 @@
+# Pinned GKI Root Variants
+
+`Build pinned root variants` is a manual, artifact-only workflow. It produces
+three separate source trees and never combines root implementations:
+
+| Variant | Upstream | Pinned commit |
+| --- | --- | --- |
+| Classic KernelSU | `tiann/KernelSU` | `da9abf498a77d438989fea0f5f4e348b9a540c07` |
+| KernelSU Next | `KernelSU-Next/KernelSU-Next` `dev` | `234f6e040fcbca18b16d2398e1aa225712ec99ad` |
+| ReSukiSU | `ReSukiSU/ReSukiSU` | `3ef06b0fcb0960dc9563256fe26a58e892663387` |
+| NoMount | `maxsteeel/nomount` `dev` | `c52936b229c25a4b0e41b6627f7d3bc5eaaaf2b5` |
+
+The workflow resolves the selected target to its immutable SUSFS pin:
+
+| GKI target | SUSFS branch tip pinned for this workflow |
+| --- | --- |
+| android12-5.10 | `3c14ad549f826b1f53878ec8c12253efebeed75a` |
+| android13-5.10 | `f81aaf10e9560282052bb61dd931315c2ca3e617` |
+| android13-5.15 | `ccb1918684b27644d17a6c842f57b60ae5966025` |
+| android14-5.15 | `0463ac089308014e8c22cc6a4558e0d6d2a53e08` |
+| android14-6.1 | `e287d59066380bf6de4396532d4a42edf4408701` |
+| android15-6.6 | `be7b7ef49a1e1b189c3abf00eacaa7ebdb4168c1` |
+| android16-6.12 | `f37930f374ef88de990d6abea0c67d0ea28c1edc` |
+
+All pins were resolved on 2026-08-13 from the named upstream branches. A pin
+is re-verified after checkout; a mismatch, missing upstream `fs/nomount`
+integration, or existing root integration fails the build.
+
+The workflow snapshots ABI/KMI controls before root integration and verifies
+them immediately afterward. It then snapshots the approved, target-specific
+SUSFS and device-patch ABI updates before NoMount integration and requires
+NoMount to leave that baseline unchanged. The guard covers legacy ABI symbol
+lists and the Android 16 Bazel ABI/staging/symbol definitions. It does not
+remove protected exports, bypass ABI checks, build a bypass image, create
+releases, or claim device compatibility.
+
+## Build-verified only artifact metadata
+
+Successful builds upload a `<target>-Metadata` artifact containing
+machine-readable JSON. Its `status` is **`Build-verified only`** only after the
+corresponding AnyKernel3 and NoMount metamodule artifacts are uploaded and their
+GitHub Actions API URLs and GitHub-issued `sha256` digests are recorded. Each
+record also includes the build method, root implementation/manager/version and
+commit, SUSFS and NoMount revisions, Android branch/KMI, kernel source commit,
+and provenance run URL.
+
+NoMount integration invokes the upstream `kernel/setup.sh` by its full immutable
+commit URL and passes that same SHA as the script argument. Each kernel artifact
+also receives a separately uploaded NoMount metamodule archive built from the
+same SHA; its artifact URL and SHA-256 digest are included in the metadata
+record. Kernel and metamodule revisions must match exactly.
+
+The metadata `catalog` object makes publication eligibility explicit:
+
+| Field | Required value |
+| --- | --- |
+| `availability` | `eligible-with-provenance-and-checksums` |
+| `device_compatibility` | `not-validated` |
+| `flashability` | `not-guaranteed` |
+| `boot` | `not-guaranteed` |
+
+This status means that CI completed the source build and artifact integrity
+metadata is available. It is not device validation and does not claim device
+compatibility, flashability, or a successful boot. A catalog or release
+publisher may expose a successful build artifact only with its provenance URL
+and both kernel and matching NoMount metamodule checksums. It must mark an
+unbuilt, failed, or metadata-incomplete entry unavailable and provide no
+download.
+
+This workflow remains artifact-only: it does not create releases or publish a
+catalog. Any separate publisher must enforce this metadata contract.
+
+Runs dispatched before this metadata contract was added cannot retroactively
+contain these metadata artifacts. Their artifact digests remain available from
+the GitHub Actions artifact API, but they must not be represented as complete
+metadata-contract records.