diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aee915..99a14a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,39 @@ jobs: - 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: + args: format --check + - name: ruff check + uses: astral-sh/ruff-action@v4.0.0 + with: + 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@v5 + with: + path: | + /usr/local/cargo/registry + /usr/local/cargo/git + zygisk/target + lsposed/native/target + key: cargo-${{ runner.os }}-lint-${{ hashFiles('zygisk/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 - name: Verify generated iface lists are up to date run: | @@ -74,17 +107,12 @@ jobs: # Kotlin - name: ktlint run: ktlint "lsposed/**/*.kt" - # Gobley's cargo plugin reads ANDROID_NDK_ROOT (not _HOME) to find the - # NDK at gradle configure time. The CI image only sets _HOME; export - # _ROOT here until the next image rebuild bakes it in. - - name: Android lint - run: | - export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" - cd lsposed && ./gradlew --no-daemon :app:lint - - name: Kotlin unit tests - run: | - export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" - cd lsposed && ./gradlew --no-daemon :app:testDebugUnitTest + # Single Gradle invocation: lint + tests share one configuration + # phase + warm daemon. Configures Gobley's cargo plugin once instead + # of twice. ANDROID_NDK_ROOT is baked into the CI image + # (Dockerfile ENV), no manual export needed. + - name: Android lint + Kotlin unit tests + run: cd lsposed && ./gradlew :app:lint :app:testDebugUnitTest kmod: runs-on: ubuntu-latest @@ -191,6 +219,13 @@ jobs: 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 }} @@ -218,14 +253,23 @@ jobs: storeFile=$KEYSTORE_PATH EOF - # Gobley's cargo plugin reads ANDROID_NDK_ROOT (not _HOME) to find the - # NDK at gradle configure time. The CI image only sets _HOME; export - # _ROOT here until the next image rebuild bakes it in. + # 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: | - export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" - cd "$GITHUB_WORKSPACE/lsposed" && ./gradlew --no-daemon assembleRelease - cp app/build/outputs/apk/release/app-release.apk "$GITHUB_WORKSPACE/vpnhide.apk" + 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 diff --git a/docs/development.md b/docs/development.md index 6968267..261bdbf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -103,6 +103,10 @@ CI runs the same checks. See [.github/workflows/ci.yml](../.github/workflows/ci. python3 scripts/codegen-interfaces.py git diff --quiet # must be clean +# Python (ruff, config in pyproject.toml). uvx runs without installing anything global. +uvx ruff format --check +uvx ruff check + # Rust cd zygisk && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings cd ../lsposed/native && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings @@ -116,8 +120,7 @@ gcc -O2 -Wall -Werror -o /tmp/test_iface_lists kmod/test_iface_lists.c && /tmp/t # Kotlin ktlint "lsposed/**/*.kt" -cd lsposed && ./gradlew --no-daemon :app:lint -cd lsposed && ./gradlew --no-daemon :app:testDebugUnitTest +cd lsposed && ./gradlew :app:lint :app:testDebugUnitTest ``` ## Build versions diff --git a/kmod/build.py b/kmod/build.py index 9cdab35..89e0d96 100755 --- a/kmod/build.py +++ b/kmod/build.py @@ -45,8 +45,11 @@ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -from build_lib import get_build_version, make_zip, version_sort_key # type: ignore[import-not-found] - +from build_lib import ( # type: ignore[import-not-found] + get_build_version, + make_zip, + version_sort_key, +) # Module file name on disk after `make`. KMOD_KO = "vpnhide_kmod.ko" @@ -128,13 +131,9 @@ def native_build_one( module_prop = staging / "module.prop" content = module_prop.read_text(encoding="utf-8") - content = re.sub( - r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE - ) + content = re.sub(r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE) if re.search(r"^gkiVariant=", content, flags=re.MULTILINE): - content = re.sub( - r"^gkiVariant=.*", f"gkiVariant={kmi}", content, flags=re.MULTILINE - ) + content = re.sub(r"^gkiVariant=.*", f"gkiVariant={kmi}", content, flags=re.MULTILINE) else: content = content.rstrip() + f"\ngkiVariant={kmi}\n" update_json_url = ( @@ -219,9 +218,7 @@ def find_runtime() -> tuple[str, bool]: sys.exit(1) -def container_build_one( - runtime: str, is_podman: bool, repo_root: Path, kmi: str -) -> None: +def container_build_one(runtime: str, is_podman: bool, repo_root: Path, kmi: str) -> None: image = f"ghcr.io/ylarod/ddk-min:{kmi}-{DDK_IMAGE_TAG}" mount_spec = f"{repo_root}:/work" cmd = [runtime, "run", "--rm"] @@ -314,10 +311,7 @@ def main() -> int: parser.add_argument( "--kdir", type=str, - help=( - "Kernel source directory (overrides KDIR/KERNEL_SRC). Implies " - "native mode." - ), + help=("Kernel source directory (overrides KDIR/KERNEL_SRC). Implies native mode."), ) parser.add_argument( "--clang-dir", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1b28798 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +# Ruff config — applies to every .py in the repo. +# All our scripts are stdlib-only by policy (see scripts/build_lib.py +# header), so ruff is a dev/CI tool here, never a runtime dependency. + +[tool.ruff] +target-version = "py312" # CI image (Ubuntu 24.04) ships Python 3.12 +line-length = 100 +extend-exclude = [ + # third-party / vendored — not our code, don't lint + "zygisk/third_party", + # cargo build outputs that may contain stray .py files + "**/target", + # local-only worktrees / agent state, untracked + ".claude", +] + +[tool.ruff.lint] +# Conservative rule set: pycodestyle (E/W) + pyflakes (F) + isort (I) +# + bugbear foot-guns (B) + pyupgrade (UP) + simplifications (SIM). +# No pylint-style noise, no mypy. +select = ["E", "F", "W", "I", "B", "UP", "SIM"] diff --git a/scripts/changelog_lib.py b/scripts/changelog_lib.py index eaf4a7f..309e571 100644 --- a/scripts/changelog_lib.py +++ b/scripts/changelog_lib.py @@ -117,8 +117,8 @@ def parse_fragment(path: Path) -> dict: if date_match.start() > en_match.start(): raise ValueError(f"{path.name}: date line must appear before the language sections") - en_body = text[en_match.end():ru_match.start()].strip() - ru_body = text[ru_match.end():].strip() + en_body = text[en_match.end() : ru_match.start()].strip() + ru_body = text[ru_match.end() :].strip() if not en_body: raise ValueError(f"{path.name}: empty English section") if not ru_body: diff --git a/scripts/codegen-interfaces.py b/scripts/codegen-interfaces.py index fdf2091..952b0fb 100755 --- a/scripts/codegen-interfaces.py +++ b/scripts/codegen-interfaces.py @@ -464,7 +464,7 @@ def emit_rust(rules: list[Rule], tests: list[TestVector]) -> str: expected = "true" if t.is_vpn else "false" lines.append( f" assert_eq!(matches_vpn({rust_byte_lit(t.name)}), {expected}, " - f"\"matches_vpn({t.name!r})\");" + f'"matches_vpn({t.name!r})");' ) lines.append(" }") lines.append("}") diff --git a/scripts/release.py b/scripts/release.py index 40ee82a..d7f0431 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -114,8 +114,7 @@ def main() -> int: for past in data.get("history", []): if past.get("version") == version: console.print( - f"[red]error:[/red] v{version} already exists in history[]. " - "Pick a new version.", + f"[red]error:[/red] v{version} already exists in history[]. Pick a new version.", ) return 1 @@ -169,11 +168,14 @@ def main() -> int: console.print() console.print("[bold]Next steps:[/bold]") - console.print(f" git commit -am \"chore: release v{version}\"") + console.print(f' git commit -am "chore: release v{version}"') console.print(f" git tag v{version} && git push && git push origin v{version}") - console.print(" # CI builds artifacts and creates a DRAFT release — review on the Releases page, click Publish") + console.print( + " # CI builds artifacts and creates a DRAFT release — " + "review on the Releases page, click Publish" + ) console.print(" ./scripts/update-json.sh") - console.print(f" git commit -am \"chore: update-json for v{version}\"") + console.print(f' git commit -am "chore: update-json for v{version}"') console.print(" git push") return 0 diff --git a/scripts/stats.py b/scripts/stats.py index b8d81f4..93f4d1b 100755 --- a/scripts/stats.py +++ b/scripts/stats.py @@ -22,9 +22,7 @@ def github_token() -> str | None: if token := os.environ.get("GITHUB_TOKEN"): return token try: - out = subprocess.run( - ["gh", "auth", "token"], capture_output=True, text=True, check=True - ) + out = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True) return out.stdout.strip() or None except (FileNotFoundError, subprocess.CalledProcessError): return None @@ -34,9 +32,7 @@ headers = {"Accept": "application/vnd.github+json"} if token := github_token(): headers["Authorization"] = f"Bearer {token}" -resp = httpx.get( - "https://api.github.com/repos/okhsunrog/vpnhide/releases", headers=headers -) +resp = httpx.get("https://api.github.com/repos/okhsunrog/vpnhide/releases", headers=headers) resp.raise_for_status() releases = resp.json() @@ -53,7 +49,13 @@ for release in releases: total = sum(a["download_count"] for a in assets) grand_total += total - table = Table(title=f"{release['tag_name']} ({total} downloads)", title_style="bold", show_header=False, box=None, padding=(0, 1)) + table = Table( + title=f"{release['tag_name']} ({total} downloads)", + title_style="bold", + show_header=False, + box=None, + padding=(0, 1), + ) table.add_column("Asset", style="cyan") table.add_column("Count", justify="right", style="yellow") table.add_column("Bar", style="green", no_wrap=True) diff --git a/zygisk/build.py b/zygisk/build.py index 4c98cad..5fac1e3 100755 --- a/zygisk/build.py +++ b/zygisk/build.py @@ -18,7 +18,11 @@ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -from build_lib import get_build_version, make_zip, version_sort_key # type: ignore[import-not-found] +from build_lib import ( # type: ignore[import-not-found] + get_build_version, + make_zip, + version_sort_key, +) def main() -> int: