name: CI on: push: branches: [main] tags: ['v*'] pull_request: workflow_dispatch: permissions: contents: read packages: read env: RUFF_VERSION: "0.15.20" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: setup: runs-on: ubuntu-latest outputs: image: ${{ steps.img.outputs.image }} ddkqemu: ${{ steps.img.outputs.ddkqemu }} kpmlegacy: ${{ steps.img.outputs.kpmlegacy }} steps: - id: img env: REPO: ${{ github.repository }} run: | echo "image=ghcr.io/${REPO,,}/ci:latest" >> "$GITHUB_OUTPUT" echo "ddkqemu=ghcr.io/${REPO,,}/ddk-qemu" >> "$GITHUB_OUTPUT" echo "kpmlegacy=ghcr.io/${REPO,,}/kpm-qemu-legacy:latest" >> "$GITHUB_OUTPUT" # Build the raw-syscall probe from the PR checkout once, then feed that exact # binary to every QEMU job. The long-lived QEMU images also carry a fallback # copy, but relying on it makes a PR that adds/changes a probe fail until the # post-merge image rebuild catches up. qemu-native-probes: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 - name: Build state-aware socket bind probe run: | TC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" mkdir -p .artifacts/qemu-native-probes "$TC/aarch64-linux-android24-clang" -static -O2 -Wall -Wextra -Werror \ -o .artifacts/qemu-native-probes/bind-probe kmod/test/bind-probe.c "$TC/llvm-strip" .artifacts/qemu-native-probes/bind-probe - name: Upload QEMU native probes uses: actions/upload-artifact@v7 with: name: qemu-native-probes path: .artifacts/qemu-native-probes if-no-files-found: error lint: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: submodules: recursive fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" # Python lint + format (ruff). Fast (~100 ms on 1800 LoC) so it # runs first — fails before the slow Rust/Gradle steps. - name: ruff format uses: astral-sh/ruff-action@v4.0.0 with: version: ${{ env.RUFF_VERSION }} args: format --check - name: ruff check uses: astral-sh/ruff-action@v4.0.0 with: version: ${{ env.RUFF_VERSION }} args: check # Cache cargo deps + target dirs for clippy/test. Same key shape as # zygisk + lsposed jobs — when those run on the same Cargo.lock the # restore-keys fallback shares warm artifacts across jobs. - name: Cache cargo uses: actions/cache@v6 with: path: | /usr/local/cargo/registry /usr/local/cargo/git target zygisk/target lsposed/native/target key: cargo-${{ runner.os }}-lint-${{ hashFiles('Cargo.lock', 'lsposed/native/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}-lint- # Gradle cache (deps + configuration cache + wrapper). cache-read-only # on PRs so only main pushes write — keeps the cache from churning on # every PR's branch-scoped key. - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.event_name == 'pull_request' }} # Codegen — regenerate from the data/*.toml sources and fail on drift. - name: Verify generated files are up to date (iface lists + hook registry) run: | python3 scripts/codegen-interfaces.py python3 scripts/codegen-hooks.py if ! git diff --quiet; then echo "::error::a data/*.toml is out of sync with generated files. Run scripts/codegen-interfaces.py and scripts/codegen-hooks.py and commit the result." >&2 git --no-pager diff exit 1 fi # Rust - name: rustfmt run: | cargo fmt --check cd lsposed/native && cargo fmt --check - name: clippy (protocol + activator) run: cargo clippy -p vpnhide_protocol -p vpnhide_protocol_diff -p vpnhide_activator --all-targets -- -D warnings # `--tests` so generated test modules are also linted # (catches `bool_assert_comparison`-style regressions in # codegen output). - name: clippy (zygisk) run: cargo ndk -t arm64-v8a clippy -p vpnhide_zygisk --tests -- -D warnings - name: clippy (lsposed native) run: cd lsposed/native && cargo ndk -t arm64-v8a clippy --tests -- -D warnings - name: cargo test (workspace) run: cargo test -p vpnhide_protocol -p vpnhide_protocol_diff -p vpnhide_activator -p vpnhide_zygisk - name: cargo test (lsposed native) run: cd lsposed/native && cargo test # Shell — module-side scripts (Magisk/KSU) and host-side dev tooling. # shellcheck is preinstalled in the CI image (apt). The list is # explicit so we don't accidentally pick up vendored .sh from # zygisk/third_party. - name: shellcheck # SC2034: `SKIPUNZIP` and the `for i in seq …` counters look unused # to shellcheck — Magisk reads SKIPUNZIP externally, the # counters are loop iterators we don't read by name. # SC3043: `local` is "not POSIX" but Android's /system/bin/sh # (mksh on Pixel) supports it, and our module-side # scripts always run there. # The inline `apt-get install` is a one-job fallback for the # window between this PR landing and the next ci-image rebuild # (the Dockerfile bake also adds shellcheck). After the rebuild # this no-ops in <1s. run: | if ! command -v shellcheck >/dev/null; then apt-get update -qq && apt-get install -y --no-install-recommends shellcheck >/dev/null fi shellcheck -x -e SC2034,SC3043 \ kmod/module/customize.sh kmod/module/post-fs-data.sh kmod/module/service.sh kmod/module/uninstall.sh \ kmod/kpm/module/customize.sh kmod/kpm/module/post-fs-data.sh kmod/kpm/module/service.sh kmod/kpm/module/uninstall.sh \ zygisk/module/customize.sh zygisk/module/service.sh zygisk/module/uninstall.sh \ portshide/module/customize.sh portshide/module/service.sh \ portshide/module/uninstall.sh \ scripts/clean-device.sh scripts/update-json.sh \ kmod/test/run.sh kmod/test/build-kernel.sh kmod/test/init.sh \ kmod/test/run-kpm.sh kmod/test/init-kpm.sh kmod/test/build-source-kernel.sh # C (kernel module) - name: clang-format run: scripts/clang-format-c.sh --check - name: kmod iface-list test (host build) run: | cd kmod gcc -O2 -Wall -Werror -o /tmp/test_iface_lists test_iface_lists.c /tmp/test_iface_lists # Shared backend-agnostic logic + the control/stats protocol parser / # serialiser. The protocol harness runs the language-independent golden # vectors (kmod/shared/protocol_vectors.tsv) — the C side of the # cross-language parity check (docs/protocol.md §8). - name: shared-logic + protocol host tests run: | cd kmod/shared gcc -O2 -Wall -Wextra -Werror -I.. -o /tmp/test_vpnhide_logic test_vpnhide_logic.c /tmp/test_vpnhide_logic gcc -O2 -Wall -Wextra -Werror -I.. -o /tmp/test_protocol test_protocol.c /tmp/test_protocol protocol_vectors.tsv # Kotlin # ktlint = formatting/style; detekt = complexity/length/dead-code/bug # patterns; cpdCheck = cross-file copy-paste. detekt runs clean with no # baseline (inherent spots are @Suppress-ed at the call site); cpdCheck # is clean at its 100-token threshold. See lsposed/config/detekt/ and # the cpd block in lsposed/build.gradle.kts. - name: ktlint run: ktlint "lsposed/app/src/**/*.kt" # Single Gradle invocation: detekt + cpd + lint + tests share one # configuration phase + warm daemon. Configures Gobley's cargo plugin # once instead of four times. ANDROID_NDK_ROOT is baked into the CI # image (Dockerfile ENV), no manual export needed. - name: detekt + CPD + Android lint + Kotlin unit tests # `:app:lintDebug` (not `:app:lint`) runs Lint on the debug # variant only — release-variant Lint covers R8/ProGuard issues # and is reserved for ad-hoc local runs / future tag CI. run: cd lsposed && ./gradlew :app:detekt cpdCheck :app:lintDebug :app:testDebugUnitTest kmod-activator: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Cache cargo uses: actions/cache@v6 with: path: | /usr/local/cargo/registry /usr/local/cargo/git target key: cargo-${{ runner.os }}-kmod-activator-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-${{ runner.os }}-kmod-activator- - name: Build kmod activator run: cargo ndk -t arm64-v8a build --release -p vpnhide_activator --bin kmod - name: Upload kmod activator uses: actions/upload-artifact@v7 with: name: kmod-activator path: target/aarch64-linux-android/release/kmod if-no-files-found: error kmod: needs: kmod-activator runs-on: ubuntu-latest strategy: matrix: kmi: - android12-5.10 - android13-5.10 - android13-5.15 - android14-5.15 - android14-6.1 - android15-6.6 - android16-6.12 # Tag here mirrors `DDK_IMAGE_TAG` in kmod/build.py — bump both # together so local builds and CI use the exact same image. container: image: ghcr.io/ylarod/ddk-min:${{ matrix.kmi }}-20260313 env: KMI: ${{ matrix.kmi }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Download kmod activator uses: actions/download-artifact@v7 with: name: kmod-activator path: .artifacts/kmod-activator - name: Build and package kernel module env: VPNHIDE_KMOD_ACTIVATOR: ${{ github.workspace }}/.artifacts/kmod-activator/kmod run: python3 kmod/build.py --kmi $KMI --inside-container - name: Upload artifact uses: actions/upload-artifact@v7 with: name: vpnhide-kmod-${{ matrix.kmi }} path: vpnhide-kmod-${{ matrix.kmi }}.zip if-no-files-found: error # Runtime gate: boot a real GKI kernel for each KMI in QEMU and assert the # module actually hides VPN state (per-vector A/B), not just that it compiled. # Catches register/inlining bugs that source + build checks can't (e.g. the # static-function rt_fill_info register issue). Runs in the per-KMI ddk-qemu # image (built by qemu-image.yml) which bakes a bootable kernel + its build # tree; the module is built here against that tree so it matches the kernel. # QEMU runs under TCG (no KVM on hosted runners) — slow but fine. See kmod/test/. kmod-qemu: needs: [setup, qemu-native-probes] runs-on: ubuntu-latest strategy: fail-fast: false matrix: kmi: - android12-5.10 - android13-5.10 - android13-5.15 - android14-5.15 - android14-6.1 - android15-6.6 - android16-6.12 container: # Lowercased base from setup (GHCR names must be lowercase); credentials # because it's a repo-owned (private) package, like the other jobs. image: ${{ needs.setup.outputs.ddkqemu }}:${{ matrix.kmi }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} env: KMI: ${{ matrix.kmi }} VPNHIDE_BIND_REQUIRED: "1" VPNHIDE_BIND_BIN: ${{ github.workspace }}/.artifacts/qemu-native-probes/bind-probe steps: - uses: actions/checkout@v7 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Download QEMU native probes uses: actions/download-artifact@v7 with: name: qemu-native-probes path: .artifacts/qemu-native-probes - name: Mark QEMU native probes executable run: chmod +x "$VPNHIDE_BIND_BIN" - name: Build module + QEMU runtime test # Build the module against the BAKED kernel tree (VPNHIDE_QEMU_KSRC) so # CFI/vermagic/struct layouts match the kernel run.sh boots — building # against the GKI kdir mismatches and panics on some KMIs (e.g. 6.12). # run.sh boots the baked kernel (VPNHIDE_QEMU_IMAGE/ROOTFS from ENV). run: | CLANG_BIN="$(ls -d /opt/ddk/clang/*/bin | head -1)" make -C kmod KERNEL_SRC="$VPNHIDE_QEMU_KSRC" CLANG_DIR="$CLANG_BIN" VPNHIDE_QEMU_KO="$PWD/kmod/vpnhide_kmod.ko" kmod/test/run.sh "$KMI" # KPM backend on the SAME baked GKI kernels as kmod-qemu, but patched with # KernelPatch instead of insmod (run-kpm.sh). Reuses the ddk-qemu image: the # baked Image/rootfs + KernelPatch tool/runtime (VPNHIDE_KP_BIN) make this # hermetic. Legacy pre-GKI kernels (4.14/4.19/5.4) are a separate job/image. kpm-qemu: needs: [setup, qemu-native-probes] runs-on: ubuntu-latest strategy: fail-fast: false matrix: kmi: - android12-5.10 - android13-5.10 - android13-5.15 - android14-5.15 - android14-6.1 - android15-6.6 - android16-6.12 container: image: ${{ needs.setup.outputs.ddkqemu }}:${{ matrix.kmi }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} env: KMI: ${{ matrix.kmi }} VPNHIDE_BIND_BIN: ${{ github.workspace }}/.artifacts/qemu-native-probes/bind-probe steps: - uses: actions/checkout@v7 with: submodules: recursive # KernelPatch headers needed by `make kpm` - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Download QEMU native probes uses: actions/download-artifact@v7 with: name: qemu-native-probes path: .artifacts/qemu-native-probes - name: Mark QEMU native probes executable run: chmod +x "$VPNHIDE_BIND_BIN" - name: Build .kpm + KPM QEMU runtime test # `make kpm` builds the relocatable .kpm against the KernelPatch # submodule (no kernel tree needed). run-kpm.sh patches the baked Image # with KernelPatch (VPNHIDE_KP_BIN), embeds the .kpm, and boots the A/B. # VPNHIDE_GAI_REQUIRED: the bionic getifaddrs probe is baked into the # image, so a skip means it's missing — fail rather than drop coverage. env: VPNHIDE_GAI_REQUIRED: "1" VPNHIDE_BIND_REQUIRED: "1" run: | CLANG_BIN="$(ls -d /opt/ddk/clang/*/bin | head -1)" make -C kmod kpm CLANG_DIR="$CLANG_BIN" kmod/test/run-kpm.sh "$KMI" # Legacy pre-GKI kernels (4.14/4.19/5.4) — the KPM's whole reason to exist # (the .ko can't serve them). One image holds all three from-source Images; # `-cpu cortex-a57` (those kernels fault on `-cpu max`) with software PAN # compiled in, so the user/kernel pointer-domain bug class is caught here on # the exact cores where it lives. kpm-qemu-legacy: needs: [setup, qemu-native-probes] runs-on: ubuntu-latest strategy: fail-fast: false matrix: ver: ["4.14", "4.19", "5.4"] container: image: ${{ needs.setup.outputs.kpmlegacy }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} env: VPNHIDE_BIND_BIN: ${{ github.workspace }}/.artifacts/qemu-native-probes/bind-probe steps: - uses: actions/checkout@v7 with: submodules: recursive # KernelPatch headers needed by `make kpm` - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Download QEMU native probes uses: actions/download-artifact@v7 with: name: qemu-native-probes path: .artifacts/qemu-native-probes - name: Mark QEMU native probes executable run: chmod +x "$VPNHIDE_BIND_BIN" - name: Build .kpm + legacy KPM QEMU test # plain clang from the legacy image; the baked Image + rootfs + KP tools # come from ENV (VPNHIDE_QEMU_ROOTFS / VPNHIDE_KP_BIN). The KMI arg is # just a label here — VPNHIDE_QEMU_IMAGE picks the from-source kernel. # VPNHIDE_GAI_REQUIRED: the bionic getifaddrs probe is baked into the # image, so a skip means it's missing — fail rather than drop coverage. env: VPNHIDE_GAI_REQUIRED: "1" VPNHIDE_BIND_REQUIRED: "1" run: | make -C kmod kpm VPNHIDE_QEMU_IMAGE="/opt/qemu/legacy/${{ matrix.ver }}/Image" \ VPNHIDE_QEMU_CPU=cortex-a57 \ kmod/test/run-kpm.sh android12-5.10 # The KPM ships as ONE cross-version flashable module (Magisk/KSU format, # APatch reads it too), not a raw .kpm: `make kpm` builds the relocatable # object, kmod/kpm/build.py stages the boot scripts + stamps module.prop. # The kpm-qemu / kpm-qemu-legacy jobs above are the runtime *test* gate; this # job is the *publish* gate. kpm: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: submodules: recursive # KernelPatch headers needed by `make kpm` fetch-depth: 0 # get_build_version's `git describe` - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Build KPM module zip env: UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-kpm.json run: | # clang + lld are needed for the freestanding `-r` link. Bake them # into the ci image to drop this fallback (one-job apt, same pattern # as the shellcheck step); after the rebuild this no-ops in <1s. if ! command -v clang >/dev/null || ! command -v ld.lld >/dev/null; then apt-get update -qq && apt-get install -y --no-install-recommends clang lld >/dev/null fi python3 kmod/kpm/build.py - name: Upload artifact uses: actions/upload-artifact@v7 with: name: vpnhide-kpm path: vpnhide-kpm.zip if-no-files-found: error zygisk: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: submodules: recursive fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Cache cargo uses: actions/cache@v6 with: path: | /usr/local/cargo/registry /usr/local/cargo/git zygisk/target key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Build module zip env: UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-zygisk.json run: | cd zygisk python3 ./build.py - name: Upload artifact uses: actions/upload-artifact@v7 with: name: vpnhide-zygisk path: zygisk/target/vpnhide-zygisk.zip if-no-files-found: error lsposed: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Cache cargo uses: actions/cache@v6 with: path: | /usr/local/cargo/registry /usr/local/cargo/git lsposed/native/target key: cargo-${{ runner.os }}-lsposed-${{ hashFiles('lsposed/native/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}-lsposed- # Gradle cache (deps + configuration cache + wrapper). cache-read-only # on PRs so only main pushes write the cache. - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Set up keystore env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | KEYSTORE_PATH="$GITHUB_WORKSPACE/lsposed/release.jks" if [ -n "$KEYSTORE_BASE64" ]; then echo "$KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH" else echo "ANDROID_KEYSTORE_BASE64 is empty (fork PR); generating an ephemeral keystore. Resulting APK is signed with a throwaway key and is NOT suitable for release." KEYSTORE_PASSWORD=ephemeral KEY_ALIAS=ephemeral keytool -genkeypair -v \ -keystore "$KEYSTORE_PATH" \ -storepass "$KEYSTORE_PASSWORD" \ -keypass "$KEYSTORE_PASSWORD" \ -alias "$KEY_ALIAS" \ -keyalg RSA -keysize 4096 -validity 365 \ -dname "CN=vpnhide-fork-ci, O=vpnhide, C=US" fi # Build via printf — `%s` swallows the value verbatim with no # shell expansion of $, backticks, backslashes, or `!`. Heredoc # without single-quoted EOF would re-expand each line. { printf 'password=%s\n' "$KEYSTORE_PASSWORD" printf 'keyAlias=%s\n' "$KEY_ALIAS" printf 'storeFile=%s\n' "$KEYSTORE_PATH" } > "$GITHUB_WORKSPACE/lsposed/keystore.properties" # Release tags get the full assembleRelease (R8/ProGuard, signed APK # ready for the GitHub release). PRs and main pushes get assembleDebug # — same code paths exercised, no R8 step (~1.5–2 min faster). # `case` instead of `[[`: container jobs default to /bin/sh (POSIX). - name: Build APK run: | cd "$GITHUB_WORKSPACE/lsposed" case "$GITHUB_REF" in refs/tags/v*) ./gradlew assembleRelease cp app/build/outputs/apk/release/app-release.apk "$GITHUB_WORKSPACE/vpnhide.apk" ;; *) ./gradlew assembleDebug cp app/build/outputs/apk/debug/app-debug.apk "$GITHUB_WORKSPACE/vpnhide.apk" ;; esac - name: Upload artifact uses: actions/upload-artifact@v7 with: name: vpnhide-apk path: vpnhide.apk if-no-files-found: error portshide: needs: setup runs-on: ubuntu-latest container: image: ${{ needs.setup.outputs.image }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Mark workspace safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Cache cargo uses: actions/cache@v6 with: path: | /usr/local/cargo/registry /usr/local/cargo/git target key: cargo-${{ runner.os }}-portshide-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-${{ runner.os }}-portshide- - name: Package ports module zip env: UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-ports.json run: | cd portshide python3 ./build-zip.py mv vpnhide-ports.zip "$GITHUB_WORKSPACE/vpnhide-ports.zip" - name: Upload artifact uses: actions/upload-artifact@v7 with: name: vpnhide-ports path: vpnhide-ports.zip if-no-files-found: error release: needs: [kmod, kpm, zygisk, lsposed, portshide] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest # Only the release job needs write — used by softprops/action-gh-release # below to create the draft GitHub release. lint/build jobs run on the # workflow-level `contents: read`. permissions: contents: write steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Download all artifacts uses: actions/download-artifact@v8 with: path: dist/ merge-multiple: true - name: Extract release notes from CHANGELOG.md run: | TAG="${{ github.ref_name }}" awk -v t="^## ${TAG}\$" '$0~t{flag=1;next} /^## v/{flag=0} flag' \ CHANGELOG.md > release-notes.md echo "=== release-notes.md ===" cat release-notes.md - name: Create draft release uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} body_path: release-notes.md generate_release_notes: true draft: true files: | dist/*.zip dist/*.apk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}