The `code-review` plugin is dry-run by default — it formats the review
into the Job Summary but does NOT publish a PR comment unless `--comment`
is passed in the slash invocation. That's why every auto-review run so
far finished green with permission_denials_count > 0 and zero comments
on the PR: the action ran, Claude reviewed, but the plugin's last
sentence was "No --comment argument was provided, so no GitHub comment
will be posted."
Adding `--comment` to the prompt. Permissions were never the blocker —
the `claude[bot]` GitHub App already has write on issues + PRs at
install time, and the on-demand workflow (`@claude` mentions) has been
posting fine without any of our workflow-level changes.
Earlier commit e977af0 raised pull-requests/issues to `write` in both
workflows on the assumption that workflow-level permissions gate the
Claude action's GitHub API. They don't — the action uses an OIDC token
exchanged for the `claude[bot]` GitHub App installation token, whose
permissions are configured at App install time and already include
read+write on issues + pull requests. Workflow `permissions:` only
control the default `secrets.GITHUB_TOKEN`, which the action doesn't
use unless `github_token: ${{ secrets.GITHUB_TOKEN }}` is passed in.
Revert the perms back to read-only (now matches reality) and add a
short comment explaining why — so the next reader doesn't try the
same dead end.
Also enable `display_report: true` on the auto-review job so Claude's
full review text shows up in the Actions log, even when it classifies
its inline findings as low-confidence and skips publishing them. Right
now we couldn't tell whether the action had nothing to flag or whether
the inline classifier filtered everything out — display_report makes
that visible without leaking secrets (it's the same review body that
would have been posted as a comment).
Both Claude workflows had only read-level scopes, so the auto-review
job ran for ~3 minutes per PR and silently dropped its findings — the
post-buffered-inline-comments step reported "No buffered inline
comments" because GitHub denied the API write. Same with the on-demand
@claude responder.
claude-code-review.yml: pull-requests read -> write
claude.yml: pull-requests read -> write
issues read -> write
Deliberately keeping `contents: read` everywhere — Claude advises,
doesn't commit. If we ever want @claude-driven code edits, we'd add
that scope explicitly to the on-demand workflow only.
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.
#106 added \`cargo install uniffi-bindgen --version "^0.29" --locked\`,
which fails ci-image rebuilds:
error: could not find \`uniffi-bindgen\` in registry \`crates-io\`
with version \`^0.29\`
Two errors in the original change:
1. The crate Gobley installs is \`gobley-uniffi-bindgen\` (its own
fork on crates.io at 0.3.7), not upstream \`uniffi-bindgen\`.
2. Gobley installs the binary into \`app/build/gobley-tools-install/
uniffi-bindgen/\`, not \`~/.cargo/bin\`. A globally pre-installed
binary wouldn't satisfy the task's UP-TO-DATE check anyway.
\`org.gradle.caching=true\` from #106 already makes
\`installUniffiBindgen\` go UP-TO-DATE on warm runs (verified locally),
so the optimisation is in effect via the build cache instead.
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.
Bumps actions/checkout v4→v6, actions/cache v4→v5,
actions/upload-artifact v4→v7, actions/download-artifact v4→v8
to silence the Node.js 20 deprecation warnings GitHub is emitting
ahead of the June 2026 cutoff.
softprops/action-gh-release stays on v2 (third-party, wasn't in the
deprecation list and v3 would need a separate compatibility review).
Mirror the kmod/zygisk plumbing so KernelSU-Next / Magisk pick up
portshide updates automatically:
- scripts/update-version.sh bumps portshide/module/module.prop along
with the other modules when VERSION changes
- scripts/update-json.sh writes update-json/update-ports.json pointing
at the current release zip
- CI appends updateJson=.../update-ports.json to the portshide
module.prop before zipping, matching kmod/zygisk
- Dashboard reports portshide version mismatches as issues, with the
same up/down/different wording the other modules use
Adds a portshide job mirroring the simple kmod zip packaging (no build
needed, just zip the module directory). Artifact lands next to the
other release zips so the gh-release step picks it up for tagged
builds.
- update-version.sh generates per-KMI update JSON files for kmod and one
for zygisk, pointing to GitHub Release artifacts
- CI injects updateJson URL into module.prop before packaging zips
- module.prop in repo stays clean (no updateJson), CI appends it per-variant
- Update version mismatch issue texts to direct users to KernelSU/Magisk
Modules for updating
- Fix versionName/versionCode back to 0.4.2 (was accidentally 0.4.3 from
test bump)
- Replace top bar filter icon with inline "Show system apps" checkbox
- Add hint card explaining L/K/Z layer toggles and Zygisk caveat
- Move showSystem state inside AppPickerScreen
- Fix RELEASE_OR_CODENAME lint error (requires API 30, min is 29)
- Mark technical check strings as translatable="false"
- Add Android lint step to CI
- Apply ktlint formatting
- Remove CheckRunnerService and :checks process — the ECONNREFUSED
issue was caused by Android per-app network restriction, not Vector
runtime. Checks run directly in the main process now.
- Delete test-app entirely — all diagnostics are now in the VPN Hide app
- Remove test-app from CI, lint, .gitignore, and update-version.sh
- Add lsposed/native to version update script and CI lint
- Add signingConfigs to both build.gradle.kts files, reading keystore
credentials from keystore.properties (gitignored, local absolute path)
- Both debug and release builds use the same signing key for consistent
signatures between local and CI builds
- CI decodes keystore from ANDROID_KEYSTORE_BASE64 secret, builds
assembleRelease instead of assembleDebug
- Add keystore-related files to .gitignore for test-app
- Rename lint-rust → lint, add clang-format and ktlint checks
- Add cargo test step for zygisk unit tests
- Install clang-format and ktlint 1.8.0 in CI Docker image