Package the cross-version .kpm as one flashable module (kmod/kpm/module +
kmod/kpm/build.py -> vpnhide-kpm.zip), analogous to the .ko and Zygisk
modules, per protocol §7.4:
- post-fs-data.sh loads the KPM via the KernelPatch runtime early in boot.
Keyless KPatch-Next (Magisk/KSU) loads automatically; APatch (superkey)
records awaiting_superkey for the app. Single-active guard: refuses to load
when the .ko backend is installed (§1.5).
- service.sh resolves package->UID once PackageManager is up, pushes a
'vpnhide 1 config' snapshot via 'kpatch kpm ctl0', and feeds the lsposed
UID file (the always-active Java layer).
- customize.sh seeds the persistent /data/adb/vpnhide_kpm config dir.
CI gains a 'kpm' publish job (build + upload vpnhide-kpm.zip) wired into the
release job's needs; the new module scripts join the shellcheck list. The
kpm-qemu jobs remain the runtime test gate.
The boot-script CLI invocations follow §7.3 and are fail-safe (they record
load_status and exit rather than risk a bootloop); the exact on-device
kpatch CLI paths get confirmed in device testing.
Implement the docs/protocol.md §4 wire format in the freestanding
shared/vpnhide_logic.h (shared verbatim by the .ko and KPM):
- vpnhide_parse_header / peek_kind — mandatory header, version fuse
- vpnhide_parse_config — debug + target uid/hookmask, 0x-hex u32 with
width-overflow reject, dup-uid last-wins, unknown-keyword skip,
ASCII-only (tab allowed as a separator), CRLF strip
- vpnhide_format_stats / format_status — lowercase-out hex, sparse stats
- vpnhide_clamp_to_line — KPM single-buffer truncation on a line boundary
Add the language-independent golden vectors (protocol_vectors.tsv) and the
C host harness (test_protocol.c) that pins every §8 Layer-0 corner; wire it
plus the previously-unrun test_vpnhide_logic.c into CI lint.
data/hooks.toml is the global hook id space + status error codes shared by
the control/stats protocol: bit N of a config mask == hook id N == the id in
a stats line. scripts/codegen-hooks.py renders the matching id enums,
per-backend 'own' masks, and error codes for every language that touches the
protocol (kmod/KPM C, zygisk Rust, lsposed-native Rust, app Kotlin), so the
numbering can never diverge between backends. Wired into the CI drift-check
beside the iface-list codegen.
The script runs under uv (PEP 723, '#!/usr/bin/env -S uv run' so plain
./scripts/codegen-hooks.py works), matching the repo's other tooling; no
external deps. Generated outputs are rustfmt- and ktlint-clean by
construction. Registry is append-only: ids are permanent so old stats keep
meaning and old readers skip unknown ids.
The gai_getifaddrs vector skips (still exit 0) when no bionic probe is
available — correct for a dev box without an NDK, but in CI the probe is
baked into the image, so a skip there means it's missing/broken and would
let the job go green with the addr-fill hook silently unchecked. Add a
VPNHIDE_GAI_REQUIRED gate (set by both kpm-qemu jobs): when set, an
unavailable probe is a hard failure instead of a silent coverage drop.
Completes KPM CI coverage with the kernels the .ko backend can't serve. Adds
a single lightweight `kpm-qemu-legacy` image (debian + one Bootlin gcc, no
DDK, no kernel tree) that bakes all three from-source Images via
build-source-kernel.sh, plus the Alpine rootfs + KernelPatch tool/runtime +
clang. Built by a new `build-legacy` job in qemu-image.yml (triggered by the
new Dockerfile / build-source-kernel.sh paths).
The `kpm-qemu-legacy` job (matrix 4.14/4.19/5.4) builds the .kpm and runs
run-kpm.sh against each Image with `-cpu cortex-a57` (these kernels fault on
`-cpu max`). With software PAN compiled into them (qemu.config), this is where
the user/kernel pointer-domain bug class is actually caught — the dev_ioctl
fix's regression test now lives in CI, not just on a physical Pixel.
With this, both backends are gated across the whole supported range every PR:
.ko + KPM on GKI 5.10–6.12, and KPM on 4.14/4.19/5.4.
Adds a `kpm-qemu` job that runs the KPM harness (run-kpm.sh) on the same
baked GKI kernels as `kmod-qemu`, across all 7 KMIs — KernelPatch-patched +
.kpm-embedded boot instead of insmod. The KPM was untested in CI until now;
this closes that gap for the GKI range (4.14/4.19/5.4 follow in a separate
legacy job + image).
Hermetic, reusing the ddk-qemu image:
- bake the KernelPatch tool + runtime (kptools-linux + kpimg-linux, pinned
KP_VER) into /opt/qemu/kp and expose VPNHIDE_KP_BIN, so run-kpm.sh skips its
per-run release download;
- add gcc-aarch64-linux-gnu so the harness's static getifaddrs probe builds;
- run-kpm.sh honors VPNHIDE_KP_BIN (falls back to the per-run cache locally).
The job builds the relocatable .kpm against the KernelPatch submodule
(submodules: recursive) with the DDK clang — no kernel tree needed — then
run-kpm.sh boots the baked Image with KernelPatch and runs the A/B vectors.
Touching the Dockerfile/qemu.config triggers the image rebuild that bakes
these in (qemu-image.yml).
Routine dependency refresh after auditing every manifest against the
upstream registries — the app/Rust runtime deps were already on their
latest stable, so this only touches dev tooling and the CI image.
- PMD CPD toolVersion 7.8.0 -> 7.25.0 (cpdCheck still passes clean)
- ruff 0.15.18 -> 0.15.20 (pyproject required-version + CI RUFF_VERSION in sync)
- CI image rustc 1.95.0 -> 1.96.0
- CI image NDK r28b -> r28c, matching the Gradle ndkVersion 28.2.13676358
that was already pinned (removes a CI/build NDK mismatch)
- actions/cache v5 -> v6
- Build the test module against VPNHIDE_QEMU_KSRC (the baked kernel tree) so
CFI/vermagic/struct layouts match the kernel it boots on — building against
the GKI kdir mismatched and panicked on android16-6.12.
- Pull the ddk-qemu image via a lowercased base from the setup job (GHCR names
must be lowercase) with a credentials block, matching the other repo-owned
image jobs (lint/zygisk/lsposed).
For each KMI, boot the baked GKI kernel (from the ddk-qemu image) in QEMU and
run kmod/test, asserting the module actually hides VPN state per vector — not
just that it compiled. Turns the build-only kmod matrix into a real per-version
runtime gate that catches register/inlining regressions (how rt_fill_info was
found). The module is built here against the GKI kdir and loads on the baked
kernel (same source/vermagic).
Adds the infrastructure to run the kmod QEMU harness in CI:
- .github/docker/ddk-qemu/Dockerfile: FROM ddk-min:<kmi> + qemu-system-aarch64
+ a virtio GKI kernel (kernel/common@<kmi> + qemu.config on gki_defconfig,
built with the DDK clang) + the Alpine rootfs. The module is NOT baked: a
kdir-built module loads on this kernel (same source/vermagic), so CI reuses
the existing kmod build artifact.
- .github/workflows/qemu-image.yml: matrix build+push of
ghcr.io/<owner>/ddk-qemu:<kmi> for all 7 KMIs; runs only on recipe changes,
monthly, or on demand (kernels change seldom).
- run.sh: honor VPNHIDE_QEMU_IMAGE/ROOTFS/KO so the same script uses the baked
artifacts in CI and the local cache otherwise.
- ci.yml: lint the new harness scripts via shellcheck.
- README: CI rollout (two steps) + ready-to-apply kmod-qemu gate job.
Verified the kdir module loads on the virtio kernel and passes 7/7 vectors
(android12-5.10). The kmod-qemu gate job itself lands after the images are
built (documented), so this commit doesn't reference not-yet-existing images.
detekt now runs clean with no baseline and CPD is clean at its 100-token
threshold, so both can gate. Added to the lint job's single Gradle invocation
(:app:detekt cpdCheck :app:lintDebug :app:testDebugUnitTest); CPD flipped to
ignoreFailures=false. AGENTS.md updated: opt out inherent findings with
@Suppress at the call site, not a baseline.
Six small review-list items rolled together — all CI/dev-tooling, no
runtime behaviour change.
#12 Dockerfile: pin Rust 1.95.0 and cargo-ndk 4.1.2 (was floating
`stable` + latest cargo-ndk on monthly rebuild). Versions live
in ENV vars to make the next bump a one-line edit.
#13 Add shellcheck to lint job. SC2034/SC3043 excluded — Magisk
reads SKIPUNZIP externally; Android's /system/bin/sh (mksh on
Pixel) does support `local` despite POSIX. Verified locally
that the 11 .sh files (module-side + dev tooling) pass.
shellcheck baked into the CI image via apt; inline apt-get
fallback covers the window before image rebuild.
#24 ci.yml keystore.properties: replace heredoc with `printf '%s\n'`.
Heredoc without single-quoted EOF re-expands $, backticks and
backslashes in the password — printf takes the value verbatim.
#31 scripts/release.py::patch_file now hard-fails when a regex
pattern doesn't match (was silently leaving stale versions).
#32 Split rotate_fragments_into_history into rotate + delete steps
so release.py can save_json + write_md *before* unlinking the
fragment files. If anything in between fails, fragments are
still on disk and the run is retryable.
#37 codegen-interfaces.py: emit `assert!(matches_vpn(…), msg)` /
`assert!(!matches_vpn(…), msg)` instead of
`assert_eq!(matches_vpn(…), true/false, msg)` —
clippy::bool_assert_comparison was firing on every generated
row under `cargo clippy --tests`. Both generated test modules
regenerated. CI's clippy steps now also pass `--tests` so this
class of regression is caught.
Profiling the warm-cache run on PR #105 showed three remaining hot spots
in the Gradle phase:
installUniffiBindgen 52s ← cargo install on every CI build
cargoBuildAndroidArm64Debug 30s ← Rust crate compile
lintAnalyze* (3 variants) 43s ← AGP Lint × main + unit + androidTest
This PR cuts the first one entirely and trims the third.
- Dockerfile: pre-install uniffi-bindgen 0.29.x in the CI image so
Gobley's :app:installUniffiBindgen task finds it ready instead of
rebuilding it from sources on every run. Triggers a ci-image
rebuild on merge — wait for that workflow to finish before merging
consumers (or the first lint/lsposed run will still hit the old
image and behave as before).
- lsposed/gradle.properties: enable build cache + configuration
cache. Verified locally: `./gradlew :app:assembleDebug
--configuration-cache` reports "Configuration cache entry stored"
cleanly with Gobley 0.3.7 + AGP 8.9.3 + Kotlin 2.1.20.
- lsposed/app/build.gradle.kts: `lint { checkTestSources = false }`.
Skips lintAnalyzeDebugUnitTest / lintAnalyzeDebugAndroidTest. Test
sources here are pure JVM unit-test logic — functional bugs caught
by :app:testDebugUnitTest, no Android-lifecycle code to lint.
Deliberately leave `checkReleaseBuilds` at its default so ad-hoc
`./gradlew :app:lint` still catches R8/ProGuard issues.
- .github/workflows/ci.yml: `:app:lint` -> `:app:lintDebug`. Lints
the debug variant only on PRs; release-variant Lint stays
available locally / for future tag-time CI.
- docs/development.md: refresh local-lint snippet.
Expected effect on warm cache (cumulative on top of PR #105):
lint 286s -> ~190s (3m10s, -32%)
lsposed 227s -> ~130s (2m10s, -42%)
Repo had ~1800 lines of Python (kmod/build.py, scripts/*, zygisk/build.py,
portshide/build-zip.py) with no formatter or linter. Long-lived scripts
like scripts/release.py and scripts/codegen-interfaces.py benefit from
catching unused-import / undefined-name / outdated-syntax issues early.
pyproject.toml — ruff config, target-py312, line-length 100,
rules E F W I B UP SIM. Excludes zygisk/third_party,
target/, .claude/.
ci.yml — astral-sh/ruff-action@v4 for `format --check` and `check`,
ahead of the slow Rust/Gradle steps so it fails fast.
docs/development.md — add `uvx ruff …` to the local-lint snippet.
Cleanup applied (`ruff format` + `ruff check --fix`):
- reformat: kmod/build.py, scripts/{changelog_lib,codegen-interfaces,
release,stats}.py, zygisk/build.py
- I001: split multi-name imports onto separate lines after the
sys.path.insert prelude (kmod/build.py, zygisk/build.py)
- E501 manual: wrap one console.print line in scripts/release.py
Stdlib-only invariant from scripts/build_lib.py is preserved — ruff is
a dev/CI tool, not imported at runtime.
The two slowest CI jobs were both Gradle:
lint 6m41s (Android lint = 264s of it)
lsposed 6m44s (assembleRelease = 307s of it)
Cargo was already cached; Gradle was not.
Changes:
- gradle/actions/setup-gradle@v6 in lint and lsposed jobs. Caches
~/.gradle/caches, wrapper, configuration cache. cache-read-only on
PRs so only main pushes write it.
- lint job now has a cargo cache too (was missing). Combined key for
both Cargo.lock files.
- lint: Android lint and Kotlin unit tests run in one Gradle
invocation (./gradlew :app:lint :app:testDebugUnitTest). Saves a
second Gobley/AGP configuration phase + JVM startup.
- lsposed: assembleDebug for PRs and main pushes, assembleRelease only
for v* tags. R8/ProGuard runs only when the artifact actually goes
into a release.
- Drop --no-daemon: with one invocation per job (or one warm daemon
between two), keeping the daemon is cheaper than killing it.
- Drop the manual `export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"`. The
CI image's Dockerfile already sets ANDROID_NDK_ROOT (line 63), so
the workaround is redundant.
Two related changes that ship together because they touch the same
build-script + docs surface and were verified together on-device.
16 KiB alignment
- zygisk/build.rs: pass `-Wl,-z,max-page-size=16384` to lld so the
cdylib's LOAD segments line up on 16 KiB pages. NDK r28+ already
does this by default, but the flag keeps r27 builds compatible.
- lsposed/native/build.rs: new file, same flag, for libvpnhide_checks.so.
- docs/development.md: bumped the NDK requirement to r28+ and noted
the 16 KiB rationale.
Verified via `llvm-readelf -l`: both libvpnhide_zygisk.so and
libvpnhide_checks.so now show `Align 0x4000` on every LOAD segment.
Unified build entry points
- kmod/build.py replaces kmod/build-zip.py. Single script that
auto-detects whether to build natively (we're inside the DDK image
or `--kdir` was passed) or to spawn `ghcr.io/ylarod/ddk-min` via
podman/docker. CI uses the same script with `--inside-container`.
- zygisk/build-zip.py renamed to zygisk/build.py for symmetry; logic
unchanged.
- kmod/BUILDING.md rewritten — local build is now one command:
`./kmod/build.py --kmi android14-6.1` (or `--all`). The old
hand-rolled podman/docker recipes are gone.
- .github/workflows/ci.yml updated to call the new entry points.
The DDK image tag in CI now has a comment pointing at
`DDK_IMAGE_TAG` in kmod/build.py as the source of truth.
- README.{md,en.md}, kmod/README.md, zygisk/README.md, docs/releasing.md,
scripts/build_lib.py: reference updates.
- README.en.md: also fixes a "bacame" typo and tightens the Windows
zygisk-build note (the aux.rs / libgit2 issue is still real).
Verified end-to-end on Pixel 8 Pro (husky, android14-6.1, Android 16):
APK installs, kmod + zygisk modules load, all 26 self-checks PASS in
Enforcing, 22/26 PASS in Permissive (the same 4 by-design FAILs as
before — kmod doesn't cover those paths in Permissive).
Workflow-level `contents: write` was granted to every job — lint,
zygisk build, lsposed build, portshide build, kmod matrix — even
though only the release job needs it (to create the draft GitHub
release via softprops/action-gh-release@v2). Tighten to the
least-privilege default of `contents: read` at the workflow level
and override with `permissions: contents: write` on the release job
alone. Reduces blast radius if any of the lint/build jobs ever runs
untrusted code from a PR.
The zygisk job has had this for a while; lsposed/native was rebuilding
the uniffi/serde/quinn deps from scratch every run. Same shape as the
zygisk cache, separate cache key so the two jobs don't fight over a
shared `target/` (different crate, different artifacts).
Real cause of the lsposed/lint NPE on CI: Gobley's
RustAndroidTarget.ndkToolchainDir resolves the NDK by checking, in
order, the explicit `ndkRoot` parameter, `<sdkRoot>/ndk/<latestVersion>`,
then `$ANDROID_NDK_ROOT`. The CI image installs the NDK as a separate
tree at /opt/android-ndk and exports `ANDROID_NDK_HOME`, not
`ANDROID_NDK_ROOT` — so all three lookups return null and Gobley's `!!`
produces a bare `NullPointerException` during `:app` configuration.
Locally my shell exports `ANDROID_NDK_ROOT` (Android Studio convention),
which is why the issue only surfaces in CI.
Bake `ANDROID_NDK_ROOT` into the CI Dockerfile and export it inline in
the lint / lsposed gradle steps so this PR's CI passes before the image
rebuilds. Revert the prior `rustup target add x86_64-unknown-linux-gnu`
and `--stacktrace` debug additions — that was a wrong-hypothesis
workaround (the host target is already installed by `rustup-init`).
Gobley's cargo plugin enumerates Kotlin targets at gradle configure
time and queries rustup for each one — including the JVM host target,
even though we never build for it (`androidUnitTest = false` skips
wiring the JVM cargo build into Android unit tests, but the build
entry is still created at configure time).
Without `x86_64-unknown-linux-gnu` installed, that lookup returns
null and `:app:lint` / `assembleRelease` die with a bare
`NullPointerException` during project configuration.
Add the target as a workflow step in the lint and lsposed jobs so
this PR's CI passes immediately, and bake it into the CI Dockerfile
so subsequent image rebuilds carry it.
`libc::ioctl`'s second arg is `Ioctl`, which is `c_int` on android-arm64
but `c_ulong` on linux x86_64. Hardcoding `as i32` made the host build
of the lsposed/native test harness fail with a type mismatch, so
`cargo test --lib` couldn't compile and the generated `iface_lists`
unit tests in this crate were silently dead.
Use `as _` so the cast picks the right width per target. Then add the
matching `cargo test (lsposed native)` step in CI for symmetry with the
zygisk crate, so the codegen tests actually run.
Two follow-ups to #90 in one PR:
1. Two new match forms in data/interfaces.toml grammar:
suffix = "digits_optional" prefix + 0+ ASCII digits
suffix = "any" prefix + 1+ any chars
Needed by the upcoming whitelist (PR-B) for patterns like
`seth_lte\d*` and `v4-.+`. Not used by any current [[vpn]] rule, but
the helper functions are exercised by direct unit tests in the
generated test modules so a bug would surface before whitelist lands.
2. [[test]] vectors in data/interfaces.toml that the codegen renders
into per-language unit tests:
- zygisk + lsposed/native: #[cfg(test)] mod tests inside the
generated iface_lists.rs (run via `cargo test`)
- lsposed/app: a separate IfaceListsGeneratedTest under
src/test/kotlin (run via `:app:testDebugUnitTest`)
- kmod: a userspace test driver test_iface_lists.c — the
generated header now has __KERNEL__-guarded includes so the
same matcher compiles against libc, and a new lint step builds
and runs it via gcc.
36 fixed vectors today; trivial to grow as new rules / corner cases
come up. CI catches drift on the next push: any single matcher that
disagrees with the toml fails its job.
No production behavior change — generated matches_vpn / vpnhide_iface_is_vpn
/ IfaceLists.isVpnIface bodies are byte-identical to before; only the
helper functions and test modules grew.
The kernel module, zygisk, lsposed-native, and the LSPosed Kotlin module
each had their own hand-written list of VPN interface name prefixes,
and the four had drifted: kmod/zygisk/HookEntry knew utun/l2tp/gre
while lsposed-native and DiagnosticsScreen only knew tun/wg/ppp/tap/
ipsec/xfrm. So the self-test could PASS while the hooks were actually
hiding more interfaces.
Move the rules to data/interfaces.toml and render four matchers from it
via scripts/codegen-interfaces.py — one per language target. A new lint
job re-runs the codegen and fails if anything drifts.
The match grammar is intentionally tiny so each codegen target
implements it without depending on regex (kernel C can't):
exact / prefix / prefix+digits / contains.
Side effect: native diagnostics now agree with the hooks, so the
self-test in DiagnosticsScreen will recognize utun*, l2tp*, gre* and
*vpn* substrings as VPN tunnels (previously it would silently PASS on
those). The /proc/net/route check also moved from raw substring to
whitespace-tokenized matching, which avoids matching VPN-prefix
substrings that show up by chance inside hex-encoded IP addresses.
Existing zygisk filter unit tests still pass unchanged — public API of
is_vpn_iface_bytes / is_vpn_iface_cstr is preserved, only the body now
delegates to the generated matches_vpn().
Cargo.lock files updated incidentally (synced with Cargo.toml versions
that were already 0.7.1 in the manifests).
GitHub Actions does not expose secrets to workflows triggered by PRs from
forks, so the lsposed job's `assembleRelease` was failing with a corrupt
release.jks for every external contributor. Generate a throwaway keystore
on the fly in that case so fork PRs get a green CI; signed-for-release
artifacts (push/tag runs) keep using the real secrets unchanged.
Users routinely installed the wrong GKI variant of the kmod zip and
saw no signal beyond "installed, inactive". This adds a full chain
from build to diagnostics so the wrong-variant case is both obvious
to the user and fully captured in bug reports.
Why each piece exists:
- CI stamps `gkiVariant=<kmi>` into each variant's `module.prop`
so the app can identify what was installed without guessing.
- `post-fs-data.sh` records `/data/adb/vpnhide_kmod/load_status`
(boot_id, uname -r, gki_variant, insmod exit+stderr, kprobes,
root manager) and `load_dmesg` at every boot — this survives
reboots and is the only record of insmod failures by the time
the user opens the app.
- Dashboard reads both, always computes the kernel-based
recommendation, and emits targeted issues: wrong-variant,
unknown-variant (pre-stamp zip that also failed to load),
kmod-on-unsupported-kernel, kprobes-missing, or generic
load-failed with the captured stderr.
- Diagnostics screen adds a "Kmod load trace" card so bug
reports can come in as a screenshot, and the debug zip
includes load_status + load_dmesg for deeper analysis.
Also aligns `lsposed/native/Cargo.lock` with Cargo.toml (0.6.1
→ 0.6.2) — a real stale-lock fix surfaced by the gradle build.
Dashboard compared module.prop versions (e.g. 0.6.2) directly against
BuildConfig.VERSION_NAME (which carries the git-describe suffix
0.6.2-14-g1f2205e on dev builds), always flagging mismatch. Now uses
baseVersion() to strip -N-gSHA before comparison; pre-release tags
(-rc1, -beta) are preserved.
Adds unit tests for normalizeVersion, compareSemver, baseVersion and
versionsMismatch, and wires :app:testDebugUnitTest into CI so they
actually run.
Two tweaks driven by the same goal — make the artifact list on the CI
run page less ambiguous and give the release step a review gate.
- The APK artifact was named `vpnhide`, which blends in with the other
module-zip artifacts (`vpnhide-kmod-*`, `vpnhide-zygisk`,
`vpnhide-ports`). Rename to `vpnhide-apk` so every entry in the
Artifacts list names the thing you actually get when you download it.
- Release-on-tag job now creates a DRAFT GitHub release instead of
publishing directly. Gives a chance to eyeball the release notes and
attached binaries before they go public, and avoids racing
update-json.sh against the assets becoming reachable.
docs/releasing.md and the release.py post-run hints updated to reflect
the manual Publish step and the fact that update-json still has to
wait for the release to be *published*, not just drafted (draft
release assets sit behind auth).
Yesterday's Phase 2 commit left the zygisk and portshide CI artifacts
carrying a "-dirty" suffix in their module.prop version: CI appended
`updateJson=...` to the committed module.prop *before* calling
build-zip.sh, so when build-version.sh ran inside the script it saw
the dirtied working tree and `git describe --dirty` appended "-dirty".
Move the updateJson injection into build-zip.sh itself, gated on an
UPDATE_JSON_URL env var. CI sets the env var via the job step `env:`
block; committed module.prop files are no longer touched. Local dev
builds leave the var unset and ship without updateJson, matching the
previous behaviour.
kmod CI already did things in the right order (version computed
before any module.prop edits); left that step as-is.
Add scripts/build-version.sh — a single source of truth for the
effective version string:
* HEAD on tag vX.Y.Z -> "X.Y.Z"
* N commits past tag -> "X.Y.Z-N-gSHA"
* working tree dirty -> additional "-dirty" suffix
* no git / no matching tag -> VERSION file fallback
Wired into every packaging path:
* zygisk/build-zip.sh and portshide/build-zip.sh now stage a copy of
module/ and sed-patch `version=` in the staging copy, so committed
module.prop files stay at the last-released version.
* kmod/build-zip.sh now builds into a staging copy too.
* The kmod CI step runs build-version.sh and sed-patches module.prop
before zipping (git installed in the DDK container).
* lsposed/app/build.gradle.kts exec's build-version.sh at configure
time and assigns the result to `versionName` (versionCode stays
static, still bumped by release.py).
All actions/checkout@v6 gained `fetch-depth: 0` so git describe sees
the full tag history inside CI containers.
Result: a locally built or CI-from-main APK shows up in Android
Settings as e.g. `0.6.1-16-gf86e5e5`, and the zip inside carries the
same string in module.prop; the Magisk/KSU manager displays it in the
update list. Release tag builds are indistinguishable from before —
clean `X.Y.Z`. Diagnostic bug reports now carry the exact commit in
the App version line of device_info.txt.
Split the generated markdown into two files:
- CHANGELOG.md at repo root — full history with the Keep a Changelog
header. Human-facing, discoverable from the GitHub repo page.
- update-json/changelog.md — still truncated to the last 5 versions,
for the Magisk/KSU update popup.
Both are regenerated from changelog.json on every changelog-add.py
and update-version.py run.
Also switch the CI release-notes extraction to read CHANGELOG.md so
the body is future-proof once a tag ages out of the short popup file.
Extract the current tag's section from update-json/changelog.md with
awk and pass it as body_path to softprops/action-gh-release. Keeping
generate_release_notes=true so GitHub still appends the auto PR list
and "Full Changelog" link below our handwritten summary.