From 442088d13d888737d20b6877749b389b8fcef82a Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 09:29:51 +0800 Subject: [PATCH 1/7] Run the on-device e2e on the HarmonyOS emulator in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side suite already runs on every push, but everything past the C ABI — the HAP build, the ArkTS layer and the NAPI bridge on a real HarmonyOS runtime — was untested in CI because Huawei's DevEco command-line tools and emulator image are behind an account + region gate and cannot be fetched by a runner. Add a macOS workflow that streams both from a private S3/R2 bucket and drives a booted emulator, plus the two scripts behind it: ci/package-hos-toolchain.sh packs and uploads the bundle from a machine that has them installed (3.5 GB compressed), and ci/hos-emulator-e2e.sh builds, debug-signs, boots, unlocks, installs and runs the suites. The same script is the local entry point for on-device testing. The e2e it runs is new. VpnE2e.test.ets cannot pass on the public emulator image — no guest traffic ever reaches vpn-tun there — so SocksE2e.test.ets exercises the tunnel the other way: the core is started in SOCKS mode through the NAPI bridge and fetches a marker page at 127.0.0.1:18800, an address only the host-side ssserver can resolve, so a successful fetch cannot be anything but a tunnelled one. A second spec asserts the guest cannot reach that marker on its own, which is what makes the first one meaningful. Two findings from getting it green, both now documented: Hypium.setTimeConfig() does not set a timeout — it installs a system-time provider that hypium calls .getRealTime() on, so passing it a number breaks the reporter and hangs the run after the spec body completes. Use `-s timeout ` on `aa test` instead; the call is dropped from VpnE2e.test.ets too. And a freshly booted image is locked, which `aa test` refuses to work around in developer mode, so the script wakes and unlocks the screen first. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/hos-emulator.yml | 136 ++++++++++++ .gitignore | 3 + AGENTS.md | 49 ++++- README.md | 24 ++- ci/hos-emulator-e2e.sh | 180 ++++++++++++++++ ci/package-hos-toolchain.sh | 86 ++++++++ docs/hos-emulator-vpn.md | 43 +++- entry/src/ohosTest/ets/test/List.test.ets | 2 + entry/src/ohosTest/ets/test/SocksE2e.test.ets | 197 ++++++++++++++++++ entry/src/ohosTest/ets/test/VpnE2e.test.ets | 4 +- 10 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/hos-emulator.yml create mode 100755 ci/hos-emulator-e2e.sh create mode 100755 ci/package-hos-toolchain.sh create mode 100644 entry/src/ohosTest/ets/test/SocksE2e.test.ets diff --git a/.github/workflows/hos-emulator.yml b/.github/workflows/hos-emulator.yml new file mode 100644 index 0000000..47593c5 --- /dev/null +++ b/.github/workflows/hos-emulator.yml @@ -0,0 +1,136 @@ +name: HarmonyOS emulator e2e + +# Builds the HAPs with the real DevEco toolchain, boots the HarmonyOS emulator +# and runs the on-device suites against a host-side shadowsocks server. +# +# The toolchain and the emulator image are not publicly downloadable (Huawei +# account + region check, see docs/hos-emulator-vpn.md §4), so this job pulls +# the bundle ci/package-hos-toolchain.sh uploads to a private R2 bucket. That +# needs repository secrets, which GitHub does not expose to pull requests from +# forks — hence push/dispatch only, with the main CI (ci.yml) staying the gate +# for every PR. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Keep in step with ci.yml — both build the same shared core. + SHADOWSOCKS_RUST_REF: v1.23.4 + HOS_BUNDLE_PREFIX: harmonyos-6.1.1 + CARGO_TERM_COLOR: always + +jobs: + emulator-e2e: + name: On-device e2e on the HarmonyOS emulator + # Apple silicon: the emulator binary is arm64-only and the image is + # phone_all_arm, so the guest needs a native arm64 host with HVF. + runs-on: macos-15 + timeout-minutes: 90 + # The bucket credentials are the gate: without them there is nothing to run. + if: github.repository == 'shadowsocks/shadowsocks-ohos' + steps: + - uses: actions/checkout@v4 + + - name: Check out the shared Rust core + run: | + core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" + mkdir -p "$core_dir" + git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ + https://github.com/shadowsocks/shadowsocks-rust.git \ + "$core_dir/shadowsocks-rust" + + # The runner has ~14 GB free; the toolchain (~6 GB) and the emulator image + # (~4.4 GB) do not fit alongside the preinstalled Xcodes and simulator + # runtimes. Keep the *selected* Xcode — the Rust build links through it. + - name: Free disk space + run: | + df -h / + keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" + echo "keeping $keep" + for app in /Applications/Xcode_*.app; do + [[ "$app" == "$keep" ]] || sudo rm -rf "$app" + done + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ + /Library/Developer/CoreSimulator/Profiles/Runtimes || true + df -h / + + - name: Ensure aws and zstd are present + run: | + command -v aws >/dev/null || brew install awscli + command -v zstd >/dev/null || brew install zstd + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-ohos + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/sslocal-ffi/target + key: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} + restore-keys: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- + + # Streamed straight into place: holding a 4 GB archive *and* its expanded + # contents at once would not fit on the runner. + - name: Fetch the HarmonyOS toolchain and emulator image + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + mkdir -p "$HOME/hos-tools" "$HOME/hos-images" + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ + | zstd -dc | tar -C "$HOME/hos-tools" -xf - + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-images.tar.zst" - \ + | zstd -dc | tar -C "$HOME/hos-images" -xf - + # The archive keeps the command-line-tools directory name it was + # packed from; hoist it to a fixed path for the steps below. + tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + echo "HOS_TOOLS=$tools" >> "$GITHUB_ENV" + echo "HOS_IMAGES=$HOME/hos-images" >> "$GITHUB_ENV" + df -h / + + - name: Build ssserver for the host side of the tunnel + run: | + core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" + cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver + echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" + + - name: Resolve ohpm dependencies + run: $HOS_TOOLS/bin/ohpm install + + # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in + # " — so the failure has to be grepped out of the log. + - name: ArkTS unit tests + env: + DEVECO_SDK_HOME: ${{ env.HOS_TOOLS }}/sdk + run: | + set -o pipefail + $HOS_TOOLS/bin/hvigorw --no-daemon test --mode module \ + -p module=entry -p product=default 2>&1 | tee unit-tests.log + ! grep -q "ERROR: Error in" unit-tests.log + + - name: On-device e2e + run: ci/hos-emulator-e2e.sh + + - name: Emulator and device logs + if: failure() + run: | + tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/.gitignore b/.gitignore index 14f4e69..18ee401 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ native/.tun-e2e *.keystore *.csr *.cer + +# CI toolchain bundle built by ci/package-hos-toolchain.sh +hos-bundle/ diff --git a/AGENTS.md b/AGENTS.md index 3004570..96c4d40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,9 @@ entry/ main (and only) HAP module (incl. SIP003 plugin query), SOCKS and tun config serialization (incl. ACL injection, plugin fields), subscription body parsing - src/ohosTest/ on-device tests exercising the NAPI surface - (including startTunFd) on emulator/device + src/ohosTest/ on-device tests: NAPI surface (incl. startTunFd), + SOCKS tunnel e2e (emulator-capable, run by CI), + VPN/tun e2e (real devices only) native/ sslocal-ffi/ Rust crate: C ABI over shadowsocks-service src/lib.rs extern "C" API: sslocal_start, sslocal_start_tun_fd, @@ -84,6 +85,12 @@ native/ tun-e2e-linux.sh tun packet-routing e2e (Linux + root) run-tun-e2e-docker.sh runs the tun e2e in a privileged container (works from macOS; needs cargo-zigbuild + Docker) +ci/ + hos-emulator-e2e.sh on-device e2e driver: builds + signs both HAPs, + boots/unlocks the emulator, installs, runs the + ohosTest suites against a host-side ssserver + package-hos-toolchain.sh packs the DevEco tools + emulator image and + uploads them to the private CI bucket test-e2e-host.sh host-side verification entry point (see Testing) ``` @@ -212,20 +219,40 @@ Steps (order matters — the CMake build fails if the staticlib is missing): crate path-depends on the shared `shadowsocks-rust` checkout *outside* this repository, the workflow clones it to the sibling path `../core/src/main/rust/shadowsocks-rust` at the ref in `SHADOWSOCKS_RUST_REF` - — keep that in step with shadowsocks-android's submodule pin. The ArkTS - tests and the HAP build are **not** in CI: they need the DevEco - command-line tools, which are not publicly downloadable (see - `docs/hos-emulator-vpn.md` §4); run those locally. + — keep that in step with shadowsocks-android's submodule pin. + `.github/workflows/hos-emulator.yml` covers the rest — HAP build, debug + signing and the on-device suites on a booted emulator — on a macOS runner, + for pushes to `main` and on demand. Huawei's DevEco command-line tools and + emulator image are neither publicly downloadable (`docs/hos-emulator-vpn.md` + §4) nor redistributable, so that job streams them from a private S3/R2 + bucket (secrets `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, + `R2_SECRET_ACCESS_KEY`); `ci/package-hos-toolchain.sh` builds and uploads + that bundle from a Mac that has both installed. Secrets are unavailable to + fork pull requests, which is why `ci.yml` remains the gate for every PR. - **ArkTS unit tests** — `entry/src/test` (hypium): `ss://` URL parsing, both SOCKS and tun config serialization (including ACL injection), subscription body parsing. Run from DevEco Studio or headless: `hvigorw test --mode module -p module=entry -p product=default` (failures show up as `Error in ` lines). -- **On-device tests** — `entry/src/ohosTest`: exercises the NAPI surface - (including `startTunFd`) on a HarmonyOS emulator/device from DevEco Studio, - or headless: build the ohosTest HAP (`-p module=entry@ohosTest`), sign and - install both HAPs, then - `hdc shell aa test -b com.xbt.project -m entry_test -s unittest OpenHarmonyTestRunner`. +- **On-device tests** — `entry/src/ohosTest`, run from DevEco Studio or + headless via **`ci/hos-emulator-e2e.sh`** (`HOS_TOOLS= + HOS_IMAGES=~/Library/Huawei/Sdk ci/hos-emulator-e2e.sh`), which builds and + signs both HAPs, boots + unlocks the emulator, installs, starts a host-side + `ssserver` and marker page, and runs the suites. `SKIP_EMULATOR=1` reuses an + already-running emulator, `KEEP_EMULATOR=1` leaves it up. + - `SslocalNativeTest` — NAPI surface, including `startTunFd` rejection paths. + - `SocksE2eTest` — the emulator-capable e2e: the core is started in SOCKS + mode and a marker page is fetched *through* the tunnel, addressed as + `127.0.0.1:18800` so only the host-side `ssserver` can resolve it; a + second spec asserts the guest cannot reach it directly. + - `VpnE2eTest` — excluded from CI: the emulator never delivers guest traffic + to `vpn-tun` (`docs/hos-emulator-vpn.md` §2a), so it needs real hardware. + Writing on-device tests: raise hypium's 5s per-spec timeout with + `-s timeout ` on `aa test`, **never** `Hypium.setTimeConfig()` (it sets a + system-time provider, and a number there hangs the whole run); never close a + `TCPSocket` whose `connect()` is still in flight (it kills the test process + silently); a freshly booted image must be unlocked before `aa test` can + launch anything. ## Code style and conventions diff --git a/README.md b/README.md index 7d892af..71c6b09 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,28 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator request — see `.github/workflows/ci.yml`. * **ArkTS unit tests** — `entry/src/test` (hypium) covers `ss://` URL parsing and both SOCKS and tun config serialization; run from DevEco Studio. -* **On-device tests** — `entry/src/ohosTest` exercises the NAPI surface - (including `startTunFd`) on a HarmonyOS emulator/device from DevEco Studio. +* **On-device e2e (emulator)** — `ci/hos-emulator-e2e.sh` builds and debug-signs + both HAPs, boots the HarmonyOS emulator, installs them and runs + `entry/src/ohosTest` against a shadowsocks server on the host: + + ```sh + HOS_TOOLS=~/workspace/command-line-tools HOS_IMAGES=~/Library/Huawei/Sdk \ + ci/hos-emulator-e2e.sh + ``` + + `SocksE2e.test.ets` is the real end-to-end case: it starts the core in SOCKS + mode through the NAPI bridge and fetches a marker page that is only reachable + from the far end of the tunnel (a companion spec asserts it is unreachable + without it). `SslocalNativeTest` covers the rest of the NAPI surface. + `VpnE2e.test.ets` is skipped here — the public emulator image never delivers + guest traffic to `vpn-tun`, so it is a real-device test (see + `docs/hos-emulator-vpn.md` §2a). +* **CI** — `ci.yml` runs the host-side suite on every push and pull request. + `hos-emulator.yml` runs the emulator e2e above on a macOS runner for pushes to + `main` (and on demand); since Huawei's toolchain and emulator image cannot be + downloaded by a runner or redistributed, it pulls them from a private bucket + populated by `ci/package-hos-toolchain.sh`, using the repository secrets + `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`. ## Tun mode diff --git a/ci/hos-emulator-e2e.sh b/ci/hos-emulator-e2e.sh new file mode 100755 index 0000000..6a1f40c --- /dev/null +++ b/ci/hos-emulator-e2e.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# +# On-device verification of the HarmonyOS NEXT app against the HarmonyOS +# emulator: builds and debug-signs both HAPs, boots an emulator instance, +# installs, and runs the ohosTest suites against a host-side shadowsocks +# server. +# +# Everything the guest talks to is bound to the **host's loopback**, which the +# emulator reaches as 10.0.2.2 through QEMU's user-mode network: +# +# ssserver 127.0.0.1:18388 the tunnel endpoint +# http.server 127.0.0.1:18800 the marker page, only reachable through +# that tunnel (see SocksE2e.test.ets) +# +# The VPN e2e (VpnE2e.test.ets) is deliberately not run: the public emulator +# image never delivers guest traffic to vpn-tun (docs/hos-emulator-vpn.md §2a), +# so it is a real-device test. +# +# Requires (all provided by ci/package-hos-toolchain.sh on a CI runner): +# HOS_TOOLS the DevEco command-line-tools directory (hvigorw, ohpm, sdk, +# emulator, toolchains/hdc) +# HOS_IMAGES emulator image root, i.e. the parent of system-image/ +# ssserver on PATH, or SSSERVER pointing at the binary +# +# Env knobs: +# HOS_INSTANCE emulator instance name (default: ss_ci) +# HOS_OS_VERSION image version (default: HarmonyOS 6.1.1(24)) +# HOS_TARGET hdc target (default: 127.0.0.1:5555) +# KEEP_EMULATOR=1 leave the emulator running on exit +# SKIP_EMULATOR=1 use an already-running emulator (local iteration) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$SCRIPT_DIR" + +: "${HOS_TOOLS:?set HOS_TOOLS to the command-line-tools directory}" +HOS_INSTANCE="${HOS_INSTANCE:-ss_ci}" +HOS_OS_VERSION="${HOS_OS_VERSION:-HarmonyOS 6.1.1(24)}" +HOS_TARGET="${HOS_TARGET:-127.0.0.1:5555}" +BUNDLE="com.xbt.project" + +export DEVECO_SDK_HOME="$HOS_TOOLS/sdk" +export OHOS_SDK_HOME="$HOS_TOOLS/sdk/default/openharmony" +export OHOS_NDK_HOME="${OHOS_NDK_HOME:-$OHOS_SDK_HOME/native}" +HDC="$OHOS_SDK_HOME/toolchains/hdc" +EMULATOR="$HOS_TOOLS/emulator/Emulator" +HVIGORW="$HOS_TOOLS/bin/hvigorw" + +WORK="$(mktemp -d)" +PIDS=() + +cleanup() { + local status=$? + for pid in ${PIDS[@]+"${PIDS[@]}"}; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ $status -ne 0 && -f "$WORK/hilog.txt" ]]; then + echo "=== device log (tail) ===" + tail -100 "$WORK/hilog.txt" || true + fi + if [[ -z "${SKIP_EMULATOR:-}" && -z "${KEEP_EMULATOR:-}" ]]; then + "$EMULATOR" -stop "$HOS_INSTANCE" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" + exit $status +} +trap cleanup EXIT + +step() { echo ""; echo "=== $* ==="; } + +# -------------------------------------------------------------------------- +step "1/6 build the native core" +native/build-ohos.sh + +step "2/6 build and sign both HAPs" +[[ -d oh_modules ]] || "$HOS_TOOLS/bin/ohpm" install +"$HVIGORW" --no-daemon assembleHap --mode module -p product=default -p buildMode=debug +"$HVIGORW" --no-daemon assembleHap --mode module -p module=entry@ohosTest -p product=default -p buildMode=debug +APP_HAP="entry/build/default/outputs/default/entry-default-unsigned.hap" +TEST_HAP="entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap" +native/sign-hap-debug.sh "$APP_HAP" +native/sign-hap-debug.sh "$TEST_HAP" + +# -------------------------------------------------------------------------- +step "3/6 host-side shadowsocks server and marker page" +printf 'shadowsocks-ohos e2e OK\n' > "$WORK/e2e.txt" +SSSERVER="${SSSERVER:-$(command -v ssserver)}" +"$SSSERVER" -s 127.0.0.1:18388 -k shadowsocks-ohos-e2e -m aes-256-gcm -v \ + > "$WORK/ssserver.log" 2>&1 & +PIDS+=($!) +python3 -m http.server 18800 --bind 127.0.0.1 --directory "$WORK" \ + > "$WORK/http.log" 2>&1 & +PIDS+=($!) +sleep 1 +# Sanity-check the host side before blaming the guest for a failed fetch. +curl -sf -m 5 http://127.0.0.1:18800/e2e.txt | grep -q 'e2e OK' +echo "marker page served on 127.0.0.1:18800, ssserver on 127.0.0.1:18388" + +# -------------------------------------------------------------------------- +if [[ -z "${SKIP_EMULATOR:-}" ]]; then + step "4/6 boot the emulator" + "$EMULATOR" -license accept >/dev/null + # Where the (large, signed) system images live; the emulator defaults to + # ~/Library/Huawei/Sdk, which is not where CI unpacks them. + IMAGE_ROOT=() + [[ -n "${HOS_IMAGES:-}" ]] && IMAGE_ROOT=(-imageRoot "$HOS_IMAGES") + if ! "$EMULATOR" -list 2>/dev/null | grep -qx "$HOS_INSTANCE"; then + "$EMULATOR" -create "$HOS_INSTANCE" -deviceType Phone \ + -osVersion "$HOS_OS_VERSION" -memory 4 -storage 6 \ + ${IMAGE_ROOT[@]+"${IMAGE_ROOT[@]}"} + fi + "$EMULATOR" -start "$HOS_INSTANCE" -bootmode coldboot \ + ${IMAGE_ROOT[@]+"${IMAGE_ROOT[@]}"} > "$WORK/emulator.log" 2>&1 & + PIDS+=($!) +else + step "4/6 using the already-running emulator" +fi + +# The emulator often fails to register with hdc on its own; connect explicitly. +# (`tconn` says "Connect OK" the first time and "Target is connected, repeat +# operation" afterwards, so the target list is what we poll.) +step "5/6 wait for the device" +started=$SECONDS +until "$HDC" list targets 2>/dev/null | grep -q "$HOS_TARGET"; do + [[ $((SECONDS - started)) -lt 300 ]] || { echo "emulator did not come up"; exit 1; } + "$HDC" tconn "$HOS_TARGET" >/dev/null 2>&1 || true + sleep 5 +done +# ... and for the package manager to be ready to take an install. +until "$HDC" -t "$HOS_TARGET" shell "bm dump -a" 2>/dev/null | grep -q "ID:"; do + [[ $((SECONDS - started)) -lt 300 ]] || { echo "device never became ready"; exit 1; } + sleep 5 +done +"$HDC" -t "$HOS_TARGET" shell hilog > "$WORK/hilog.txt" 2>&1 & +PIDS+=($!) + +# A freshly booted image comes up locked, and `aa test` refuses to launch the +# test ability then ("The device screen is locked ... cannot be unlocked +# automatically" in developer mode). Wake it, stop it dimming again mid-run, +# and swipe the lock screen away (coordinates suit the phone profile's +# 1320x2856 screen). +"$HDC" -t "$HOS_TARGET" shell "power-shell wakeup" >/dev/null +"$HDC" -t "$HOS_TARGET" shell "power-shell timeout -o 2147483647" >/dev/null +"$HDC" -t "$HOS_TARGET" shell "uinput -T -m 660 2400 660 900 200" >/dev/null +sleep 2 + +"$HDC" -t "$HOS_TARGET" install -r "${APP_HAP%-unsigned.hap}-signed.hap" +"$HDC" -t "$HOS_TARGET" install -r "${TEST_HAP%-unsigned.hap}-signed.hap" + +# -------------------------------------------------------------------------- +step "6/6 run the on-device suites" +# SslocalNativeTest: NAPI surface. SocksE2eTest: the tunnel round-trip. +# VpnE2eTest is excluded on purpose (real hardware only, see the header). +# +# -s timeout raises hypium's 5s per-spec default; -w bounds `aa test` itself so +# a wedged test process fails the job instead of hanging it. Note that +# Hypium.setTimeConfig() is *not* a timeout setter — it installs a system-time +# provider that hypium calls .getRealTime() on, and passing a number there +# breaks the reporter and hangs the run. +RESULT="$WORK/aa-test.log" +"$HDC" -t "$HOS_TARGET" shell "aa test -b $BUNDLE -m entry_test \ + -s unittest OpenHarmonyTestRunner \ + -s class SslocalNativeTest,SocksE2eTest \ + -s timeout 60000 -w 300" 2>&1 | tee "$RESULT" + +grep -q "^OHOS_REPORT_CODE: 0" "$RESULT" \ + || { echo "on-device tests FAILED"; grep -E "OHOS_REPORT_RESULT|Timeout" "$RESULT"; exit 1; } +# A run that reports zero tests must not pass silently. +grep -qE "OHOS_REPORT_RESULT: stream=Tests run: [1-9]" "$RESULT" \ + || { echo "no tests ran"; exit 1; } + +echo "" +echo "host-side ssserver saw:" +grep -c "established tcp tunnel" "$WORK/ssserver.log" || true +echo "" +echo "========================================" +echo " HarmonyOS on-device e2e PASSED" +echo "========================================" diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh new file mode 100755 index 0000000..df4ae61 --- /dev/null +++ b/ci/package-hos-toolchain.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Packages the HarmonyOS toolchain CI needs into two archives and uploads them +# to an S3-compatible bucket (Cloudflare R2), because Huawei's DevEco +# command-line tools and emulator images are behind an account + region-gated +# download (docs/hos-emulator-vpn.md §4) and cannot be fetched by a runner — +# nor redistributed publicly, which is why the bucket must be private. +# +# Run this on a Mac that already has both installed: +# +# HOS_TOOLS=~/workspace/command-line-tools \ +# HOS_IMAGES=~/Library/Huawei/Sdk \ +# R2_BUCKET=hos-ci R2_ENDPOINT=https://.r2.cloudflarestorage.com \ +# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \ +# ci/package-hos-toolchain.sh +# +# Produces (under $OUT, default ./hos-bundle) and uploads to +# s3://$R2_BUCKET/$PREFIX/: +# +# hos-tools.tar.zst command-line-tools, minus what a CLI build never uses +# hos-images.tar.zst the emulator system image +# manifest.txt versions and sha256 of both, for the workflow to pin +# +# Set NO_UPLOAD=1 to only build the archives locally. +# +set -euo pipefail + +HOS_TOOLS="${HOS_TOOLS:-$HOME/workspace/command-line-tools}" +HOS_IMAGES="${HOS_IMAGES:-$HOME/Library/Huawei/Sdk}" +IMAGE_SUBPATH="${IMAGE_SUBPATH:-system-image/HarmonyOS-6.1.1/phone_all_arm}" +OUT="${OUT:-$PWD/hos-bundle}" +PREFIX="${PREFIX:-harmonyos-6.1.1}" +ZSTD_LEVEL="${ZSTD_LEVEL:-10}" + +[[ -x "$HOS_TOOLS/bin/hvigorw" ]] || { echo "no hvigorw under $HOS_TOOLS"; exit 1; } +[[ -d "$HOS_IMAGES/$IMAGE_SUBPATH" ]] || { echo "no image at $HOS_IMAGES/$IMAGE_SUBPATH"; exit 1; } +command -v zstd >/dev/null || { echo "zstd required"; exit 1; } + +mkdir -p "$OUT" + +# Dropped: codelinter (~190 MB, only DevEco Studio's linter) and any *.orig +# backup copies of the emulator binary. The previewers look equally droppable +# but are not: hvigor validates the SDK's component list on every build and +# fails with "SDK component missing" if they are absent. +EXCLUDES=( + --exclude "codelinter" + --exclude "emulator/*.orig" + --exclude "*/.DS_Store" +) + +echo "=== packing tools from $HOS_TOOLS ===" +tar -C "$(dirname "$HOS_TOOLS")" "${EXCLUDES[@]}" -cf - "$(basename "$HOS_TOOLS")" \ + | zstd "-$ZSTD_LEVEL" -T0 -f -o "$OUT/hos-tools.tar.zst" + +echo "=== packing image from $HOS_IMAGES/$IMAGE_SUBPATH ===" +# Keep the system-image/... prefix: the emulator locates images by that layout +# under whatever -imageRoot it is given. +tar -C "$HOS_IMAGES" --exclude "*/.DS_Store" -cf - "$IMAGE_SUBPATH" \ + | zstd "-$ZSTD_LEVEL" -T0 -f -o "$OUT/hos-images.tar.zst" + +{ + echo "packaged: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "image: $IMAGE_SUBPATH" + grep -E "Version:|SDK:|apiVersion" "$HOS_TOOLS/version.txt" 2>/dev/null || true + echo "" + shasum -a 256 "$OUT/hos-tools.tar.zst" "$OUT/hos-images.tar.zst" | sed "s|$OUT/||" +} > "$OUT/manifest.txt" + +ls -lh "$OUT" +cat "$OUT/manifest.txt" + +if [[ -n "${NO_UPLOAD:-}" ]]; then + echo "NO_UPLOAD set; skipping upload" + exit 0 +fi + +: "${R2_BUCKET:?set R2_BUCKET}" +: "${R2_ENDPOINT:?set R2_ENDPOINT}" +command -v aws >/dev/null || { echo "aws CLI required (brew install awscli)"; exit 1; } + +echo "=== uploading to s3://$R2_BUCKET/$PREFIX/ ===" +for file in hos-tools.tar.zst hos-images.tar.zst manifest.txt; do + aws s3 cp --endpoint-url "$R2_ENDPOINT" --checksum-algorithm CRC32 \ + "$OUT/$file" "s3://$R2_BUCKET/$PREFIX/$file" +done +echo "done; point the workflow's HOS_BUNDLE_PREFIX at $PREFIX" diff --git a/docs/hos-emulator-vpn.md b/docs/hos-emulator-vpn.md index e73b524..840f20e 100644 --- a/docs/hos-emulator-vpn.md +++ b/docs/hos-emulator-vpn.md @@ -129,8 +129,10 @@ guest traffic is ever delivered to the tun on the public image**: So the missing consent app is not the only emulator gap: the policy routing that should steer app traffic into the VPN interface does not take effect -(system-side; the same flow works on real devices). An HTTP-level e2e -through the tunnel can therefore only pass on real hardware. +(system-side; the same flow works on real devices). An HTTP-level e2e **over +the tun path** can therefore only pass on real hardware. Traffic handed to the +core directly — SOCKS mode — is unaffected, and that is what CI exercises on +the emulator (see below). ### On-device e2e recipe (works on a real device) @@ -149,12 +151,37 @@ python3 -m http.server 8000 --directory --bind 0.0.0.0 hdc shell aa test -b com.xbt.project -m entry_test -s unittest OpenHarmonyTestRunner ``` -Gotchas learned: hypium's default per-spec timeout is 5 s (raise with -`Hypium.setTimeConfig(ms)` — `setTimeOut` does not exist in hypium 1.0.19); -`aa test` disconnects a previously running VPN extension of the same bundle; -the emulator's guest reaches the host at `10.0.2.2` and can also reach the -host's LAN address directly via slirp, so a successful fetch alone proves -nothing — the core's counters (or the server log) must be checked. +Gotchas learned: + +- hypium's default per-spec timeout is 5 s. Raise it with `-s timeout ` on + the `aa test` command line. **Not** with `Hypium.setTimeConfig(ms)`, despite + the name: that installs a *system-time provider* object, which hypium later + calls `.getRealTime()` on while reporting a finished spec. Handing it a + number makes that call throw inside the reporter, and the run hangs after the + spec body completes — the test process stays alive, logs nothing more, and + `aa test` sits there until its own `-w` deadline. +- `aa test` disconnects a previously running VPN extension of the same bundle. +- A freshly booted image is **locked**, and `aa test` will not launch the test + ability then ("The device screen is locked … cannot be unlocked + automatically", because the image is in developer mode). Wake and unlock it + first: `power-shell wakeup`, `power-shell timeout -o 2147483647` (so it does + not dim again mid-run) and a swipe, `uinput -T -m 660 2400 660 900 200`. +- Closing a `TCPSocket` whose `connect()` is still in flight kills the test + process outright — no JS error, no faultlog, just silence. +- The emulator's guest reaches the host at `10.0.2.2` and can also reach the + host's LAN address directly via slirp, so a successful fetch alone proves + nothing — the core's counters (or the server log) must be checked. The SOCKS + e2e sidesteps this by fetching `127.0.0.1:18800`, an address only the + host-side `ssserver` can resolve to the marker (see below). + +### The e2e that *does* run on the emulator + +Because §2a rules out any tun-based test here, the on-device test CI runs is +`entry/src/ohosTest/ets/test/SocksE2e.test.ets`: it starts the core in SOCKS +mode through the same NAPI entry point the app uses and pulls a marker page +through the tunnel, with a companion spec asserting the marker is unreachable +without it. `ci/hos-emulator-e2e.sh` drives the whole thing (build, sign, boot, +unlock, install, run) and is what `.github/workflows/hos-emulator.yml` invokes. ## 3. Emulator image signature verification diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets index 40b154e..f55dc29 100644 --- a/entry/src/ohosTest/ets/test/List.test.ets +++ b/entry/src/ohosTest/ets/test/List.test.ets @@ -1,7 +1,9 @@ import abilityTest from './Ability.test'; +import socksE2eTest from './SocksE2e.test'; import vpnE2eTest from './VpnE2e.test'; export default function testsuite() { abilityTest(); + socksE2eTest(); vpnE2eTest(); } diff --git a/entry/src/ohosTest/ets/test/SocksE2e.test.ets b/entry/src/ohosTest/ets/test/SocksE2e.test.ets new file mode 100644 index 0000000..1d65c20 --- /dev/null +++ b/entry/src/ohosTest/ets/test/SocksE2e.test.ets @@ -0,0 +1,197 @@ +/******************************************************************************* + * * + * Copyright (C) 2026 by Max Lv * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +import { afterAll, describe, expect, it } from '@ohos/hypium'; +import { socket } from '@kit.NetworkKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import sslocal from 'libsslocal.so'; + +const TAG = 'SocksE2eTest'; + +/** + * Host address as seen from the emulator's user-mode (slirp) network — QEMU + * maps it to the host's loopback, so a server bound to 127.0.0.1 on the host + * is reachable here (and only here). + */ +const HOST = '10.0.2.2'; +/** Host-side `ssserver` (see ci/hos-emulator-e2e.sh). */ +const SERVER_PORT = 18388; +const METHOD = 'aes-256-gcm'; +const PASSWORD = 'shadowsocks-ohos-e2e'; +/** SOCKS5 port the core listens on inside the guest. */ +const LOCAL_PORT = 11080; + +/** + * Marker HTTP server, bound to the **host's** loopback. Addressed as + * `127.0.0.1` on purpose: that literal is resolved on the far side of the + * tunnel, by `ssserver` on the host. From inside the guest, 127.0.0.1 is the + * guest's own loopback, where nothing listens — so a successful fetch can + * only mean the request travelled through the encrypted tunnel. (Addressing + * the host as 10.0.2.2 would prove nothing: slirp would carry it directly.) + */ +const MARKER_HOST = '127.0.0.1'; +const MARKER_PORT = 18800; +const MARKER_BODY = 'shadowsocks-ohos e2e OK'; + +const REQUEST = `GET /e2e.txt HTTP/1.0\r\nHost: ${MARKER_HOST}:${MARKER_PORT}\r\n\r\n`; + +/** JSON config for the core: one SOCKS5 local, pointing at the host ssserver. */ +function localConfig(): string { + const config: Record = { + 'locals': [Object({ + 'protocol': 'socks', + 'local_address': '127.0.0.1', + 'local_port': LOCAL_PORT, + })], + 'server': HOST, + 'server_port': SERVER_PORT, + 'password': PASSWORD, + 'method': METHOD, + }; + return JSON.stringify(config); +} + +/** Sleeps for `ms`. */ +function sleep(ms: number): Promise { + return new Promise((resolve: Function) => setTimeout(resolve, ms)); +} + +/** ASCII decode; the marker page and HTTP headers are plain ASCII. */ +function decode(data: ArrayBuffer): string { + const bytes = new Uint8Array(data); + let text = ''; + for (let i = 0; i < bytes.length; i++) { + text += String.fromCharCode(bytes[i]); + } + return text; +} + +/** + * Connects to the marker server, optionally through the core's SOCKS5 local, + * and returns what the server sent back. Rejects if the connection cannot be + * established. + * + * Closing a socket whose `connect()` is still in flight kills the test process + * (no JS error, no faultlog), so the socket is only ever closed once connected. + */ +async function fetchMarker(viaSocks: boolean, timeoutMs: number): Promise { + const tcp: socket.TCPSocket = socket.constructTCPSocketInstance(); + const connectOptions: socket.TCPConnectOptions = { + address: { address: MARKER_HOST, port: MARKER_PORT, family: 1 }, + timeout: timeoutMs, + }; + if (viaSocks) { + connectOptions.proxy = { + type: socket.ProxyTypes.SOCKS5, + address: { address: '127.0.0.1', port: LOCAL_PORT, family: 1 }, + }; + } + await tcp.connect(connectOptions); + try { + return await new Promise((resolve, reject) => { + let received = ''; + let settled = false; + const settle = (err: Error | null): void => { + if (settled) { + return; + } + settled = true; + if (err !== null) { + reject(err); + } else { + resolve(received); + } + }; + const timer = setTimeout((): void => { + settle(new Error(`no marker after ${timeoutMs}ms, got ${received.length} bytes`)); + }, timeoutMs); + tcp.on('message', (value: socket.SocketMessageInfo) => { + received += decode(value.message); + if (received.includes(MARKER_BODY)) { + clearTimeout(timer); + settle(null); + } + }); + tcp.send({ data: REQUEST }).catch((err: Error): void => { + clearTimeout(timer); + settle(err); + }); + }); + } finally { + await tcp.close(); + } +} + +/** + * On-device end-to-end test of the SOCKS path: starts the native core through + * the same NAPI entry point the app uses, then pulls a marker page through the + * encrypted tunnel to a host-side `ssserver`. + * + * Unlike `VpnE2e.test.ets` this needs no VPN consent and no tun routing, so it + * runs on the public HarmonyOS emulator image, where guest traffic never + * reaches `vpn-tun` (see docs/hos-emulator-vpn.md §2a). It is the on-device + * test CI runs; the VPN e2e stays a real-device exercise. + * + * Host side, provided by `ci/hos-emulator-e2e.sh`: + * ssserver -s 127.0.0.1:18388 -k -m aes-256-gcm + * python3 -m http.server 18800 --bind 127.0.0.1 # serving e2e.txt + */ +export default function socksE2eTest() { + describe('SocksE2eTest', () => { + afterAll(() => { + sslocal.stop(); + }); + + it('round-trips an HTTP fetch through the shadowsocks tunnel', 0, async () => { + const rc = sslocal.start(localConfig()); + if (rc !== 0) { + hilog.error(0x0000, TAG, 'sslocal.start failed: %{public}s', sslocal.lastError()); + } + expect(rc).assertEqual(0); + expect(sslocal.isRunning()).assertTrue(); + + // the core binds its SOCKS listener asynchronously + await sleep(1000); + + const body = await fetchMarker(true, 20000); + hilog.info(0x0000, TAG, 'tunnelled fetch: %{public}s', body); + expect(body.includes(MARKER_BODY)).assertTrue(); + + expect(sslocal.stop()).assertEqual(0); + expect(sslocal.isRunning()).assertFalse(); + }); + + it('cannot reach the marker without the tunnel', 0, async () => { + // Control for the test above: the marker listens on the *host's* + // loopback, so the guest must not be able to reach `127.0.0.1:18800` + // on its own. Without this, a fetch that slipped out through slirp + // would look exactly like a working tunnel. + let reached = false; + try { + const body = await fetchMarker(false, 5000); + hilog.warn(0x0000, TAG, 'direct fetch unexpectedly returned: %{public}s', body); + reached = body.includes(MARKER_BODY); + } catch (err) { + hilog.info(0x0000, TAG, 'direct fetch failed as expected: %{public}s', + JSON.stringify(err)); + } + expect(reached).assertFalse(); + }); + }); +} diff --git a/entry/src/ohosTest/ets/test/VpnE2e.test.ets b/entry/src/ohosTest/ets/test/VpnE2e.test.ets index 5b613f3..6c0113a 100644 --- a/entry/src/ohosTest/ets/test/VpnE2e.test.ets +++ b/entry/src/ohosTest/ets/test/VpnE2e.test.ets @@ -17,7 +17,7 @@ * * *******************************************************************************/ -import { describe, expect, it, Hypium } from '@ohos/hypium'; +import { describe, expect, it } from '@ohos/hypium'; import { http, vpnExtension } from '@kit.NetworkKit'; import { Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @@ -75,8 +75,6 @@ const MARKER_BODY = 'shadowsocks-ohos e2e OK'; export default function vpnE2eTest() { describe('VpnE2eTest', () => { it('routes an HTTP fetch through the shadowsocks tunnel', 0, async () => { - // starting the VPN + fetching + stats sampling exceeds hypium's 5s default - Hypium.setTimeConfig(20000); await vpnExtension.startVpnExtensionAbility(VPN_ABILITY); hilog.info(0x0000, TAG, 'vpn extension started'); try { From 610da66873535b45d2e5c77dff1a24848fc61c74 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 09:35:54 +0800 Subject: [PATCH 2/7] Split the HarmonyOS CI: hosted build, self-hosted emulator e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's Apple-silicon runners do not support nested virtualization, so they cannot run the HarmonyOS emulator — and their Intel runners, which do have HVF, cannot execute it either: both the Emulator binary and the phone_all_arm image are arm64. A hosted emulator job could only ever fail. Split the workflow (now harmonyos.yml) in two. `build` keeps everything that needs no running device — the HAP build, debug signing and the ArkTS unit tests — on a hosted macOS runner, streaming only the tools archive (the 2 GB image is no longer fetched there) and publishing the signed HAPs as artifacts. `emulator-e2e` runs ci/hos-emulator-e2e.sh on a self-hosted Apple-silicon runner labelled `harmonyos`, and is skipped unless HOS_SELF_HOSTED / HOS_TOOLS_PATH / HOS_IMAGES_PATH are set, so pushes are never left queued against a runner that is offline. That job reuses its core checkout instead of re-cloning, keeping cargo's target dir warm between runs. The scripts are unchanged: ci/hos-emulator-e2e.sh was already env-driven and stays the entry point for running the e2e by hand on any Mac that has the tools and the image. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/harmonyos.yml | 208 +++++++++++++++++++++++++++++ .github/workflows/hos-emulator.yml | 136 ------------------- AGENTS.md | 25 ++-- README.md | 19 ++- ci/package-hos-toolchain.sh | 9 +- docs/hos-emulator-vpn.md | 6 +- 6 files changed, 250 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/harmonyos.yml delete mode 100644 .github/workflows/hos-emulator.yml diff --git a/.github/workflows/harmonyos.yml b/.github/workflows/harmonyos.yml new file mode 100644 index 0000000..d1ace12 --- /dev/null +++ b/.github/workflows/harmonyos.yml @@ -0,0 +1,208 @@ +name: HarmonyOS build and on-device e2e + +# The HarmonyOS side of the project needs Huawei's DevEco command-line tools, +# which are behind an account + region gate (docs/hos-emulator-vpn.md §4) and +# cannot be redistributed — so the build job takes them from a private S3/R2 +# bucket populated by ci/package-hos-toolchain.sh. Repository secrets are not +# exposed to pull requests from forks, which is why ci.yml (host-side Rust, no +# SDK needed) stays the gate for every PR. +# +# Two jobs, because they need different machines: +# +# build everything that does not need a running device — the HAP +# build, debug signing and the ArkTS unit tests. Runs on a +# GitHub-hosted macOS runner on every push to main. +# emulator-e2e the on-device suites against a booted emulator. **Cannot run +# on a GitHub-hosted runner**: the Emulator binary is arm64-only +# and the image is phone_all_arm, so it needs an Apple-silicon +# host exposing HVF — GitHub's Apple-silicon runners do not +# support nested virtualization, and their Intel runners (which +# do have HVF) cannot execute an arm64 emulator at all. It +# therefore targets a self-hosted Apple-silicon runner and only +# runs when one is configured. +# +# To enable the e2e job, register a self-hosted runner on an Apple-silicon Mac +# that has the tools and the emulator image installed, label it `harmonyos`, +# and set the repository variables: +# +# HOS_SELF_HOSTED = true +# HOS_TOOLS_PATH = /path/to/command-line-tools +# HOS_IMAGES_PATH = /path/to/image root (the parent of system-image/) +# +# Without them the job is skipped, so pushes are never left queued against an +# offline runner. `ci/hos-emulator-e2e.sh` is the same entry point either way, +# so the e2e can always be run by hand on any Mac that has both. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Keep in step with ci.yml — both build the same shared core. + SHADOWSOCKS_RUST_REF: v1.23.4 + HOS_BUNDLE_PREFIX: harmonyos-6.1.1 + CARGO_TERM_COLOR: always + +jobs: + build: + name: HAP build, signing and ArkTS unit tests + runs-on: macos-15 + timeout-minutes: 60 + if: github.repository == 'shadowsocks/shadowsocks-ohos' + steps: + - uses: actions/checkout@v4 + + - name: Check out the shared Rust core + run: | + # Cargo.toml path-depends on ../../../core/... relative to + # native/sslocal-ffi, a *sibling* of this checkout. + core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" + mkdir -p "$core_dir" + git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ + https://github.com/shadowsocks/shadowsocks-rust.git \ + "$core_dir/shadowsocks-rust" + + # ~14 GB free on the runner; the toolchain alone is ~6 GB unpacked. Keep + # the *selected* Xcode — the Rust build links through it. + - name: Free disk space + run: | + df -h / + keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" + for app in /Applications/Xcode_*.app; do + [[ "$app" == "$keep" ]] || sudo rm -rf "$app" + done + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ + /Library/Developer/CoreSimulator/Profiles/Runtimes || true + df -h / + + - name: Ensure aws and zstd are present + run: | + command -v aws >/dev/null || brew install awscli + command -v zstd >/dev/null || brew install zstd + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-ohos + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/sslocal-ffi/target + key: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} + restore-keys: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- + + # Streamed straight into place: holding the archive *and* its expanded + # contents at once would not fit. Only the tools are fetched — the 2 GB + # emulator image is for the self-hosted job, which has it locally. + - name: Fetch the HarmonyOS toolchain + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + mkdir -p "$HOME/hos-tools" + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ + | zstd -dc | tar -C "$HOME/hos-tools" -xf - + # The archive keeps the directory name it was packed from. + tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + { + echo "HOS_TOOLS=$tools" + echo "DEVECO_SDK_HOME=$tools/sdk" + echo "OHOS_SDK_HOME=$tools/sdk/default/openharmony" + echo "OHOS_NDK_HOME=$tools/sdk/default/openharmony/native" + } >> "$GITHUB_ENV" + df -h / + + - name: Resolve ohpm dependencies + run: $HOS_TOOLS/bin/ohpm install + + - name: Build the native core + run: native/build-ohos.sh + + - name: Build and sign both HAPs + run: | + set -euo pipefail + "$HOS_TOOLS/bin/hvigorw" --no-daemon assembleHap --mode module \ + -p product=default -p buildMode=debug + "$HOS_TOOLS/bin/hvigorw" --no-daemon assembleHap --mode module \ + -p module=entry@ohosTest -p product=default -p buildMode=debug + native/sign-hap-debug.sh entry/build/default/outputs/default/entry-default-unsigned.hap + native/sign-hap-debug.sh entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap + + # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in + # " — so the failure has to be grepped out of the log. + - name: ArkTS unit tests + run: | + set -o pipefail + "$HOS_TOOLS/bin/hvigorw" --no-daemon test --mode module \ + -p module=entry -p product=default 2>&1 | tee unit-tests.log + ! grep -q "ERROR: Error in" unit-tests.log + + - name: Upload the signed HAPs + uses: actions/upload-artifact@v4 + with: + name: haps + path: entry/build/default/outputs/*/*-signed.hap + if-no-files-found: error + + emulator-e2e: + name: On-device e2e on the HarmonyOS emulator + # Apple silicon with HVF; see the header for why this cannot be hosted. + runs-on: [self-hosted, macOS, ARM64, harmonyos] + timeout-minutes: 90 + if: vars.HOS_SELF_HOSTED == 'true' + steps: + - uses: actions/checkout@v4 + + # Kept between runs (the runner's workspace parent persists), so cargo's + # target dir survives and ssserver below is usually a no-op rebuild. + - name: Check out the shared Rust core + run: | + set -euo pipefail + core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" + mkdir -p "$core_dir" + if [[ -d "$core_dir/shadowsocks-rust/.git" ]]; then + git -C "$core_dir/shadowsocks-rust" fetch --depth 1 origin \ + "refs/tags/$SHADOWSOCKS_RUST_REF:refs/tags/$SHADOWSOCKS_RUST_REF" -f + git -C "$core_dir/shadowsocks-rust" checkout -f "$SHADOWSOCKS_RUST_REF" + else + git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ + https://github.com/shadowsocks/shadowsocks-rust.git \ + "$core_dir/shadowsocks-rust" + fi + + # The runner keeps the toolchain, the emulator image and cargo's cache + # locally, so there is nothing to download and nothing to free here. + - name: Build ssserver for the host side of the tunnel + run: | + core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" + cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver + echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" + + - name: On-device e2e + env: + HOS_TOOLS: ${{ vars.HOS_TOOLS_PATH }} + HOS_IMAGES: ${{ vars.HOS_IMAGES_PATH }} + # Its own instance, so a developer's emulator on the same machine is + # left alone. + HOS_INSTANCE: ss_ci + run: ci/hos-emulator-e2e.sh + + - name: Emulator log + if: failure() + run: tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/.github/workflows/hos-emulator.yml b/.github/workflows/hos-emulator.yml deleted file mode 100644 index 47593c5..0000000 --- a/.github/workflows/hos-emulator.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: HarmonyOS emulator e2e - -# Builds the HAPs with the real DevEco toolchain, boots the HarmonyOS emulator -# and runs the on-device suites against a host-side shadowsocks server. -# -# The toolchain and the emulator image are not publicly downloadable (Huawei -# account + region check, see docs/hos-emulator-vpn.md §4), so this job pulls -# the bundle ci/package-hos-toolchain.sh uploads to a private R2 bucket. That -# needs repository secrets, which GitHub does not expose to pull requests from -# forks — hence push/dispatch only, with the main CI (ci.yml) staying the gate -# for every PR. -on: - push: - branches: [main] - paths-ignore: ['**.md', 'docs/**'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # Keep in step with ci.yml — both build the same shared core. - SHADOWSOCKS_RUST_REF: v1.23.4 - HOS_BUNDLE_PREFIX: harmonyos-6.1.1 - CARGO_TERM_COLOR: always - -jobs: - emulator-e2e: - name: On-device e2e on the HarmonyOS emulator - # Apple silicon: the emulator binary is arm64-only and the image is - # phone_all_arm, so the guest needs a native arm64 host with HVF. - runs-on: macos-15 - timeout-minutes: 90 - # The bucket credentials are the gate: without them there is nothing to run. - if: github.repository == 'shadowsocks/shadowsocks-ohos' - steps: - - uses: actions/checkout@v4 - - - name: Check out the shared Rust core - run: | - core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" - mkdir -p "$core_dir" - git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ - https://github.com/shadowsocks/shadowsocks-rust.git \ - "$core_dir/shadowsocks-rust" - - # The runner has ~14 GB free; the toolchain (~6 GB) and the emulator image - # (~4.4 GB) do not fit alongside the preinstalled Xcodes and simulator - # runtimes. Keep the *selected* Xcode — the Rust build links through it. - - name: Free disk space - run: | - df -h / - keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" - echo "keeping $keep" - for app in /Applications/Xcode_*.app; do - [[ "$app" == "$keep" ]] || sudo rm -rf "$app" - done - sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ - /Library/Developer/CoreSimulator/Profiles/Runtimes || true - df -h / - - - name: Ensure aws and zstd are present - run: | - command -v aws >/dev/null || brew install awscli - command -v zstd >/dev/null || brew install zstd - - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-unknown-linux-ohos - - - name: Cargo cache - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - native/sslocal-ffi/target - key: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} - restore-keys: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- - - # Streamed straight into place: holding a 4 GB archive *and* its expanded - # contents at once would not fit on the runner. - - name: Fetch the HarmonyOS toolchain and emulator image - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - run: | - set -euo pipefail - mkdir -p "$HOME/hos-tools" "$HOME/hos-images" - aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ - | zstd -dc | tar -C "$HOME/hos-tools" -xf - - aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-images.tar.zst" - \ - | zstd -dc | tar -C "$HOME/hos-images" -xf - - # The archive keeps the command-line-tools directory name it was - # packed from; hoist it to a fixed path for the steps below. - tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" - echo "HOS_TOOLS=$tools" >> "$GITHUB_ENV" - echo "HOS_IMAGES=$HOME/hos-images" >> "$GITHUB_ENV" - df -h / - - - name: Build ssserver for the host side of the tunnel - run: | - core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" - cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver - echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" - - - name: Resolve ohpm dependencies - run: $HOS_TOOLS/bin/ohpm install - - # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in - # " — so the failure has to be grepped out of the log. - - name: ArkTS unit tests - env: - DEVECO_SDK_HOME: ${{ env.HOS_TOOLS }}/sdk - run: | - set -o pipefail - $HOS_TOOLS/bin/hvigorw --no-daemon test --mode module \ - -p module=entry -p product=default 2>&1 | tee unit-tests.log - ! grep -q "ERROR: Error in" unit-tests.log - - - name: On-device e2e - run: ci/hos-emulator-e2e.sh - - - name: Emulator and device logs - if: failure() - run: | - tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/AGENTS.md b/AGENTS.md index 96c4d40..abb17af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,15 +220,22 @@ Steps (order matters — the CMake build fails if the staticlib is missing): repository, the workflow clones it to the sibling path `../core/src/main/rust/shadowsocks-rust` at the ref in `SHADOWSOCKS_RUST_REF` — keep that in step with shadowsocks-android's submodule pin. - `.github/workflows/hos-emulator.yml` covers the rest — HAP build, debug - signing and the on-device suites on a booted emulator — on a macOS runner, - for pushes to `main` and on demand. Huawei's DevEco command-line tools and - emulator image are neither publicly downloadable (`docs/hos-emulator-vpn.md` - §4) nor redistributable, so that job streams them from a private S3/R2 - bucket (secrets `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, - `R2_SECRET_ACCESS_KEY`); `ci/package-hos-toolchain.sh` builds and uploads - that bundle from a Mac that has both installed. Secrets are unavailable to - fork pull requests, which is why `ci.yml` remains the gate for every PR. + `.github/workflows/harmonyos.yml` covers the rest, for pushes to `main` and + on demand, in two jobs: `build` (HAP build, debug signing, ArkTS unit tests) + on a GitHub-hosted macOS runner, and `emulator-e2e` (the on-device suites on + a booted emulator) on a **self-hosted** Apple-silicon runner labelled + `harmonyos`. The e2e cannot be hosted — the Emulator binary and the image are + both arm64, so it needs HVF, which GitHub's Apple-silicon runners do not + expose (no nested virtualization) and whose Intel runners cannot run an arm64 + emulator at all. That job is skipped unless the repository variables + `HOS_SELF_HOSTED=true`, `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so + pushes never queue against an offline runner. Huawei's DevEco command-line + tools are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor + redistributable, so `build` streams them from a private S3/R2 bucket (secrets + `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`); + `ci/package-hos-toolchain.sh` builds and uploads that bundle from a Mac that + has them installed. Secrets are unavailable to fork pull requests, which is + why `ci.yml` remains the gate for every PR. - **ArkTS unit tests** — `entry/src/test` (hypium): `ss://` URL parsing, both SOCKS and tun config serialization (including ACL injection), subscription body parsing. Run from DevEco Studio or headless: diff --git a/README.md b/README.md index 71c6b09..6ad083e 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,20 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator guest traffic to `vpn-tun`, so it is a real-device test (see `docs/hos-emulator-vpn.md` §2a). * **CI** — `ci.yml` runs the host-side suite on every push and pull request. - `hos-emulator.yml` runs the emulator e2e above on a macOS runner for pushes to - `main` (and on demand); since Huawei's toolchain and emulator image cannot be - downloaded by a runner or redistributed, it pulls them from a private bucket - populated by `ci/package-hos-toolchain.sh`, using the repository secrets - `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`. + `harmonyos.yml` adds the parts that need the SDK, on pushes to `main` and on + demand. Because Huawei's toolchain cannot be downloaded by a runner or + redistributed, it is streamed from a private bucket populated by + `ci/package-hos-toolchain.sh` (repository secrets `R2_ENDPOINT`, `R2_BUCKET`, + `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`). Two jobs: + * `build` — HAP build, debug signing and the ArkTS unit tests, on a + GitHub-hosted macOS runner. + * `emulator-e2e` — the on-device suites, on a **self-hosted** Apple-silicon + runner labelled `harmonyos`. It cannot be hosted: the emulator is an + arm64-only binary running an arm64 guest, so it needs HVF, and GitHub's + Apple-silicon runners have no nested virtualization while their Intel + runners cannot execute it at all. The job is skipped unless the repository + variables `HOS_SELF_HOSTED`, `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are + set, so pushes are never left queued against an offline runner. ## Tun mode diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh index df4ae61..7d4ea6a 100755 --- a/ci/package-hos-toolchain.sh +++ b/ci/package-hos-toolchain.sh @@ -17,8 +17,13 @@ # Produces (under $OUT, default ./hos-bundle) and uploads to # s3://$R2_BUCKET/$PREFIX/: # -# hos-tools.tar.zst command-line-tools, minus what a CLI build never uses -# hos-images.tar.zst the emulator system image +# hos-tools.tar.zst command-line-tools, minus what a CLI build never uses. +# This is what the `build` job of harmonyos.yml streams. +# hos-images.tar.zst the emulator system image. CI does not fetch this — +# the emulator only runs on a self-hosted Apple-silicon +# runner, which has the image locally; it is here to +# provision such a machine (or a replacement) without +# going through Huawei's region-gated download again. # manifest.txt versions and sha256 of both, for the workflow to pin # # Set NO_UPLOAD=1 to only build the archives locally. diff --git a/docs/hos-emulator-vpn.md b/docs/hos-emulator-vpn.md index 840f20e..0893902 100644 --- a/docs/hos-emulator-vpn.md +++ b/docs/hos-emulator-vpn.md @@ -181,7 +181,11 @@ Because §2a rules out any tun-based test here, the on-device test CI runs is mode through the same NAPI entry point the app uses and pulls a marker page through the tunnel, with a companion spec asserting the marker is unreachable without it. `ci/hos-emulator-e2e.sh` drives the whole thing (build, sign, boot, -unlock, install, run) and is what `.github/workflows/hos-emulator.yml` invokes. +unlock, install, run) and is what the `emulator-e2e` job of +`.github/workflows/harmonyos.yml` invokes — on a self-hosted Apple-silicon +runner, since GitHub's hosted macOS runners cannot run this emulator (their +Apple-silicon machines have no nested virtualization, and their Intel ones +cannot execute an arm64 emulator binary at all). ## 3. Emulator image signature verification From 1ae993b668e2993b869851e43ec0774642dcb3fb Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 10:13:29 +0800 Subject: [PATCH 3/7] Authenticate to R2 with a Cloudflare API token from two secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2's S3 API accepts a Cloudflare API token in place of an S3 keypair: the access key is the token's ID and the secret is the SHA-256 of the token value. Deriving the pair in the workflow means only the token and the endpoint have to be stored, instead of four secrets, and rotating the token is a single change. The `build` job resolves the token ID through /tokens/verify (the account comes from the endpoint host) and masks both halves before use. ci/package-hos-toolchain.sh does the same derivation, so uploading and downloading take the same two variables; an explicit AWS keypair still wins when one is exported. The bucket name moves to a plain workflow env var — it discloses nothing on its own, since the account lives in the endpoint secret. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/harmonyos.yml | 31 ++++++++++++++++++++++++------- AGENTS.md | 8 +++++--- README.md | 6 ++++-- ci/package-hos-toolchain.sh | 23 +++++++++++++++++++++-- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/workflows/harmonyos.yml b/.github/workflows/harmonyos.yml index d1ace12..03a61eb 100644 --- a/.github/workflows/harmonyos.yml +++ b/.github/workflows/harmonyos.yml @@ -3,9 +3,10 @@ name: HarmonyOS build and on-device e2e # The HarmonyOS side of the project needs Huawei's DevEco command-line tools, # which are behind an account + region gate (docs/hos-emulator-vpn.md §4) and # cannot be redistributed — so the build job takes them from a private S3/R2 -# bucket populated by ci/package-hos-toolchain.sh. Repository secrets are not -# exposed to pull requests from forks, which is why ci.yml (host-side Rust, no -# SDK needed) stays the gate for every PR. +# bucket populated by ci/package-hos-toolchain.sh, authenticating with the +# repository secrets R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. +# Secrets are not exposed to pull requests from forks, which is why ci.yml +# (host-side Rust, no SDK needed) stays the gate for every PR. # # Two jobs, because they need different machines: # @@ -48,6 +49,9 @@ concurrency: env: # Keep in step with ci.yml — both build the same shared core. SHADOWSOCKS_RUST_REF: v1.23.4 + # Where ci/package-hos-toolchain.sh put the bundle. The account lives in the + # R2_ENDPOINT secret, so a bucket name on its own gives nothing away. + R2_BUCKET: shadowsocks HOS_BUNDLE_PREFIX: harmonyos-6.1.1 CARGO_TERM_COLOR: always @@ -105,15 +109,28 @@ jobs: # Streamed straight into place: holding the archive *and* its expanded # contents at once would not fit. Only the tools are fetched — the 2 GB # emulator image is for the self-hosted job, which has it locally. + # + # R2's S3 API takes a Cloudflare API token as the token's *ID* plus the + # SHA-256 of its value, so the keypair is derived here and only the token + # itself is stored. Both derived halves are masked: the ID is not secret + # by itself, but it is half a credential. - name: Fetch the HarmonyOS toolchain env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} run: | set -euo pipefail + account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" + key_id="$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/$account/tokens/verify" \ + -H "Authorization: Bearer $R2_API_TOKEN" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" + secret="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + echo "::add-mask::$key_id" + echo "::add-mask::$secret" + export AWS_ACCESS_KEY_ID="$key_id" + export AWS_SECRET_ACCESS_KEY="$secret" + export AWS_DEFAULT_REGION=auto mkdir -p "$HOME/hos-tools" aws s3 cp --endpoint-url "$R2_ENDPOINT" \ "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ diff --git a/AGENTS.md b/AGENTS.md index abb17af..cf783dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,10 +231,12 @@ Steps (order matters — the CMake build fails if the staticlib is missing): `HOS_SELF_HOSTED=true`, `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so pushes never queue against an offline runner. Huawei's DevEco command-line tools are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor - redistributable, so `build` streams them from a private S3/R2 bucket (secrets - `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`); + redistributable, so `build` streams them from a private S3/R2 bucket, using + the secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT`; the S3 + keypair is derived from them at runtime (token ID from `/tokens/verify`, + secret = SHA-256 of the token value) and masked. `ci/package-hos-toolchain.sh` builds and uploads that bundle from a Mac that - has them installed. Secrets are unavailable to fork pull requests, which is + has them installed, taking the same two variables. Secrets are unavailable to fork pull requests, which is why `ci.yml` remains the gate for every PR. - **ArkTS unit tests** — `entry/src/test` (hypium): `ss://` URL parsing, both SOCKS and tun config serialization (including ACL injection), subscription diff --git a/README.md b/README.md index 6ad083e..42a165c 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,10 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator `harmonyos.yml` adds the parts that need the SDK, on pushes to `main` and on demand. Because Huawei's toolchain cannot be downloaded by a runner or redistributed, it is streamed from a private bucket populated by - `ci/package-hos-toolchain.sh` (repository secrets `R2_ENDPOINT`, `R2_BUCKET`, - `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`). Two jobs: + `ci/package-hos-toolchain.sh`, authenticated with the repository secrets + `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT` — R2's S3 API takes + that token as its ID plus the SHA-256 of its value, which the workflow derives + at runtime. Two jobs: * `build` — HAP build, debug signing and the ArkTS unit tests, on a GitHub-hosted macOS runner. * `emulator-e2e` — the on-device suites, on a **self-hosted** Apple-silicon diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh index 7d4ea6a..1c16c3b 100755 --- a/ci/package-hos-toolchain.sh +++ b/ci/package-hos-toolchain.sh @@ -10,10 +10,14 @@ # # HOS_TOOLS=~/workspace/command-line-tools \ # HOS_IMAGES=~/Library/Huawei/Sdk \ -# R2_BUCKET=hos-ci R2_ENDPOINT=https://.r2.cloudflarestorage.com \ -# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \ +# R2_BUCKET=shadowsocks R2_ENDPOINT=https://.r2.cloudflarestorage.com \ +# R2_API_TOKEN= \ # ci/package-hos-toolchain.sh # +# R2_API_TOKEN/R2_ENDPOINT are the same two values the workflow reads from +# repository secrets; an AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY pair is used +# instead when exported. +# # Produces (under $OUT, default ./hos-bundle) and uploads to # s3://$R2_BUCKET/$PREFIX/: # @@ -83,6 +87,21 @@ fi : "${R2_ENDPOINT:?set R2_ENDPOINT}" command -v aws >/dev/null || { echo "aws CLI required (brew install awscli)"; exit 1; } +# Same credential model as the workflow: R2's S3 API accepts a Cloudflare API +# token as the token's *ID* (from /tokens/verify) plus the SHA-256 of its value, +# so only R2_API_TOKEN has to be stored anywhere. An explicit AWS keypair still +# wins if one is exported. +if [[ -n "${R2_API_TOKEN:-}" && -z "${AWS_ACCESS_KEY_ID:-}" ]]; then + account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" + AWS_ACCESS_KEY_ID="$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/$account/tokens/verify" \ + -H "Authorization: Bearer $R2_API_TOKEN" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-auto}" +fi + echo "=== uploading to s3://$R2_BUCKET/$PREFIX/ ===" for file in hos-tools.tar.zst hos-images.tar.zst manifest.txt; do aws s3 cp --endpoint-url "$R2_ENDPOINT" --checksum-algorithm CRC32 \ From 9ff430aeb93d44a98a1602f33f3acc4eabdf40c5 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 10:34:45 +0800 Subject: [PATCH 4/7] Cache the toolchain in CI instead of downloading it every run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build job pulled 1.5 GB from R2 on every push. Cache the unpacked toolchain and key it on the archive's sha256, which the bundle manifest already carries — so the only thing fetched on a normal run is that 352-byte manifest, and re-uploading a bundle invalidates the cache by itself with nothing to bump by hand. ohpm's store and the hvigor plugins are cached alongside, since both are otherwise refetched from Huawei's registries each build. The credential derivation moves into ci/r2-env.sh, sourced by both the workflow steps that need it and by the packaging script, so the three copies collapse to one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/harmonyos.yml | 59 ++++++++++++++++++++++++--------- AGENTS.md | 9 +++-- README.md | 6 ++-- ci/package-hos-toolchain.sh | 17 ++-------- ci/r2-env.sh | 31 +++++++++++++++++ 5 files changed, 88 insertions(+), 34 deletions(-) create mode 100644 ci/r2-env.sh diff --git a/.github/workflows/harmonyos.yml b/.github/workflows/harmonyos.yml index 03a61eb..f70d1c2 100644 --- a/.github/workflows/harmonyos.yml +++ b/.github/workflows/harmonyos.yml @@ -106,37 +106,51 @@ jobs: key: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} restore-keys: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- + # 352 bytes, so this runs even on a cache hit: the archive's sha256 from + # the manifest is the cache key, which means re-uploading a bundle + # invalidates the cache by itself, with nothing to bump by hand. + - name: Resolve the bundle version + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + sha="$(aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/manifest.txt" - \ + | awk '/hos-tools\.tar\.zst/ { print $1 }')" + [[ -n "$sha" ]] || { echo "no hos-tools checksum in the manifest"; exit 1; } + echo "HOS_TOOLS_SHA=$sha" >> "$GITHUB_ENV" + + - name: Cache the HarmonyOS toolchain + id: tools-cache + uses: actions/cache@v4 + with: + path: ~/hos-tools + key: hos-tools-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }} + # Streamed straight into place: holding the archive *and* its expanded # contents at once would not fit. Only the tools are fetched — the 2 GB # emulator image is for the self-hosted job, which has it locally. - # - # R2's S3 API takes a Cloudflare API token as the token's *ID* plus the - # SHA-256 of its value, so the keypair is derived here and only the token - # itself is stored. Both derived halves are masked: the ID is not secret - # by itself, but it is half a credential. - name: Fetch the HarmonyOS toolchain + if: steps.tools-cache.outputs.cache-hit != 'true' env: R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} run: | set -euo pipefail - account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" - key_id="$(curl -fsS \ - "https://api.cloudflare.com/client/v4/accounts/$account/tokens/verify" \ - -H "Authorization: Bearer $R2_API_TOKEN" \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" - secret="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" - echo "::add-mask::$key_id" - echo "::add-mask::$secret" - export AWS_ACCESS_KEY_ID="$key_id" - export AWS_SECRET_ACCESS_KEY="$secret" - export AWS_DEFAULT_REGION=auto + source ci/r2-env.sh mkdir -p "$HOME/hos-tools" aws s3 cp --endpoint-url "$R2_ENDPOINT" \ "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ | zstd -dc | tar -C "$HOME/hos-tools" -xf - + + - name: Locate the toolchain + run: | + set -euo pipefail # The archive keeps the directory name it was packed from. tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + [[ -x "$tools/bin/hvigorw" ]] || { echo "no hvigorw under $tools"; exit 1; } { echo "HOS_TOOLS=$tools" echo "DEVECO_SDK_HOME=$tools/sdk" @@ -145,6 +159,19 @@ jobs: } >> "$GITHUB_ENV" df -h / + # Small (tens of MB) but fetched from Huawei's registries on every build: + # ohpm's package store and the hvigor plugins hvigorw pulls on first run. + - name: Cache ohpm and hvigor dependencies + uses: actions/cache@v4 + with: + path: | + ~/.ohpm + ~/.hvigor + oh_modules + .hvigor + key: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}-${{ hashFiles('oh-package-lock.json5', 'oh-package.json5', 'entry/oh-package.json5') }} + restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- + - name: Resolve ohpm dependencies run: $HOS_TOOLS/bin/ohpm install diff --git a/AGENTS.md b/AGENTS.md index cf783dc..bceba46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,8 @@ ci/ ohosTest suites against a host-side ssserver package-hos-toolchain.sh packs the DevEco tools + emulator image and uploads them to the private CI bucket + r2-env.sh sourced helper: derives S3 credentials for that + bucket from R2_API_TOKEN + R2_ENDPOINT test-e2e-host.sh host-side verification entry point (see Testing) ``` @@ -233,8 +235,11 @@ Steps (order matters — the CMake build fails if the staticlib is missing): tools are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor redistributable, so `build` streams them from a private S3/R2 bucket, using the secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT`; the S3 - keypair is derived from them at runtime (token ID from `/tokens/verify`, - secret = SHA-256 of the token value) and masked. + keypair is derived from them at runtime by `ci/r2-env.sh` (token ID from + `/tokens/verify`, secret = SHA-256 of the token value) and masked. Only the + 352-byte manifest is fetched on a normal run: the unpacked toolchain is + cached under the archive's sha256 from that manifest, so a re-uploaded bundle + invalidates the cache by itself. `ci/package-hos-toolchain.sh` builds and uploads that bundle from a Mac that has them installed, taking the same two variables. Secrets are unavailable to fork pull requests, which is why `ci.yml` remains the gate for every PR. diff --git a/README.md b/README.md index 42a165c..09f8bc2 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,10 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator redistributed, it is streamed from a private bucket populated by `ci/package-hos-toolchain.sh`, authenticated with the repository secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT` — R2's S3 API takes - that token as its ID plus the SHA-256 of its value, which the workflow derives - at runtime. Two jobs: + that token as its ID plus the SHA-256 of its value, which `ci/r2-env.sh` + derives at runtime. The unpacked toolchain is cached between runs, keyed on + the archive's checksum from the bundle manifest, so re-uploading a bundle + invalidates it on its own and nothing has to be bumped by hand. Two jobs: * `build` — HAP build, debug signing and the ArkTS unit tests, on a GitHub-hosted macOS runner. * `emulator-e2e` — the on-device suites, on a **self-hosted** Apple-silicon diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh index 1c16c3b..2183b73 100755 --- a/ci/package-hos-toolchain.sh +++ b/ci/package-hos-toolchain.sh @@ -87,20 +87,9 @@ fi : "${R2_ENDPOINT:?set R2_ENDPOINT}" command -v aws >/dev/null || { echo "aws CLI required (brew install awscli)"; exit 1; } -# Same credential model as the workflow: R2's S3 API accepts a Cloudflare API -# token as the token's *ID* (from /tokens/verify) plus the SHA-256 of its value, -# so only R2_API_TOKEN has to be stored anywhere. An explicit AWS keypair still -# wins if one is exported. -if [[ -n "${R2_API_TOKEN:-}" && -z "${AWS_ACCESS_KEY_ID:-}" ]]; then - account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" - AWS_ACCESS_KEY_ID="$(curl -fsS \ - "https://api.cloudflare.com/client/v4/accounts/$account/tokens/verify" \ - -H "Authorization: Bearer $R2_API_TOKEN" \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" - AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" - export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY - export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-auto}" -fi +# Same credential derivation the workflow uses. +# shellcheck source=ci/r2-env.sh +source "$(dirname "$0")/r2-env.sh" echo "=== uploading to s3://$R2_BUCKET/$PREFIX/ ===" for file in hos-tools.tar.zst hos-images.tar.zst manifest.txt; do diff --git a/ci/r2-env.sh b/ci/r2-env.sh new file mode 100644 index 0000000..bc8d7e2 --- /dev/null +++ b/ci/r2-env.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Sourced, not executed. Derives S3 credentials for the CI bucket from the two +# values CI stores: R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. +# +# R2's S3 API accepts a Cloudflare API token as the token's *ID* — which +# /tokens/verify returns, and the account is the first label of the endpoint +# host — plus the SHA-256 of the token value. An explicit AWS keypair wins if +# one is already exported, so a plain S3 credential still works everywhere. +# +# Safe to source more than once. + +if [[ -n "${R2_API_TOKEN:-}" && -z "${AWS_ACCESS_KEY_ID:-}" ]]; then + : "${R2_ENDPOINT:?set R2_ENDPOINT alongside R2_API_TOKEN}" + _r2_account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" + AWS_ACCESS_KEY_ID="$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/$_r2_account/tokens/verify" \ + -H "Authorization: Bearer $R2_API_TOKEN" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + unset _r2_account + # The ID is not secret by itself, but it is half a credential. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::add-mask::$AWS_ACCESS_KEY_ID" + echo "::add-mask::$AWS_SECRET_ACCESS_KEY" + fi +fi + +export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-auto}" From 781001c1e1d76ddc38631a8c15dd51631159e17c Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 14:55:32 +0800 Subject: [PATCH 5/7] Split CI into one workflow per test surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml ran rustfmt, clippy, the Rust suite, the cross-compile check and the tun e2e as a single job, so a red badge said only "something in the core broke" — and a slow step held up the fast ones. harmonyos.yml had the same problem across two very different machines. Six workflows now, each naming what it covers: lint (rustfmt, clippy and shellcheck over every script), test-core, test-cross, test-tun, harmonyos-build and harmonyos-e2e. The first four gate every PR and run in parallel; the HarmonyOS pair keeps its push/dispatch trigger because fork PRs cannot see the bucket secrets. test-e2e-host.sh takes step names (tests, cross, tun; all three by default), so each workflow drives one step through the same script that developers run locally, rather than CI growing its own copy of the commands. The tun step gains TUN_E2E_REQUIRED, which CI sets: a missing /dev/net/tun now fails the job instead of skipping quietly, replacing the separate device assertion ci.yml had to do by hand. The setup every Rust workflow repeats — the sibling shadowsocks-rust checkout, the toolchain, the cargo cache — moves into the composite action .github/actions/rust-core, which is also the single place the core's pinned ref is now defined. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actionlint.yaml | 5 + .github/actions/rust-core/action.yml | 67 ++++++++ .github/workflows/ci.yml | 89 ---------- .../{harmonyos.yml => harmonyos-build.yml} | 126 ++------------ .github/workflows/harmonyos-e2e.yml | 68 ++++++++ .github/workflows/lint.yml | 47 ++++++ .github/workflows/test-core.yml | 31 ++++ .github/workflows/test-cross.yml | 37 ++++ .github/workflows/test-tun.yml | 35 ++++ AGENTS.md | 69 +++++--- README.md | 84 +++++---- docs/hos-emulator-vpn.md | 4 +- native/tun-e2e-linux.sh | 1 + test-e2e-host.sh | 159 +++++++++++------- 14 files changed, 502 insertions(+), 320 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/rust-core/action.yml delete mode 100644 .github/workflows/ci.yml rename .github/workflows/{harmonyos.yml => harmonyos-build.yml} (52%) create mode 100644 .github/workflows/harmonyos-e2e.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test-core.yml create mode 100644 .github/workflows/test-cross.yml create mode 100644 .github/workflows/test-tun.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9f3910d --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# The emulator e2e needs a self-hosted Apple-silicon runner carrying the +# HarmonyOS toolchain and image; actionlint cannot know custom labels. +self-hosted-runner: + labels: + - harmonyos diff --git a/.github/actions/rust-core/action.yml b/.github/actions/rust-core/action.yml new file mode 100644 index 0000000..2580090 --- /dev/null +++ b/.github/actions/rust-core/action.yml @@ -0,0 +1,67 @@ +name: Set up the shared Rust core +description: > + Clones the shadowsocks-rust checkout this crate path-depends on, installs the + Rust toolchain and restores the cargo cache. Every Rust workflow starts here, + so the core's pinned ref lives in exactly one place. + +inputs: + ref: + description: > + shadowsocks-rust tag to build against. Keep in step with the + core/src/main/rust/shadowsocks-rust submodule pin in shadowsocks-android, + so both platforms ship the same core. + required: false + default: v1.23.4 + targets: + description: Extra rustup targets, comma-separated. + required: false + default: '' + components: + description: Extra rustup components, comma-separated. + required: false + default: '' + +outputs: + ref: + description: The shadowsocks-rust ref that was checked out. + value: ${{ inputs.ref }} + +runs: + using: composite + steps: + - name: Check out the shared Rust core + shell: bash + run: | + set -euo pipefail + # Cargo.toml path-depends on ../../../core/... relative to + # native/sslocal-ffi, which is a *sibling* of this checkout — the + # layout this subproject has inside shadowsocks-android. + core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" + mkdir -p "$core_dir" + if [[ ! -d "$core_dir/shadowsocks-rust/.git" ]]; then + git clone --depth 1 --branch "${{ inputs.ref }}" \ + https://github.com/shadowsocks/shadowsocks-rust.git \ + "$core_dir/shadowsocks-rust" + else + # Self-hosted runners keep the workspace parent between runs. + git -C "$core_dir/shadowsocks-rust" fetch --depth 1 origin \ + "refs/tags/${{ inputs.ref }}:refs/tags/${{ inputs.ref }}" -f + git -C "$core_dir/shadowsocks-rust" checkout -f "${{ inputs.ref }}" + fi + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.targets }} + components: ${{ inputs.components }} + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/sslocal-ffi/target + # Shared across the Rust workflows: they build the same crate, and a + # cache written by one is a valid restore for the others. + key: cargo-${{ runner.os }}-${{ inputs.ref }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}-${{ inputs.ref }}- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d5f0630..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -# A new push supersedes the run still going for the same ref. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # The Rust core is a path dependency on the shadowsocks-rust checkout that - # the Android app builds (see native/sslocal-ffi/Cargo.toml). Keep this in - # step with the `core/src/main/rust/shadowsocks-rust` submodule pin in - # shadowsocks-android, so both platforms ship the same core. - SHADOWSOCKS_RUST_REF: v1.23.4 - CARGO_TERM_COLOR: always - -jobs: - # Everything that can run without the HarmonyOS SDK: the Rust core's unit - # tests, both host e2e tunnels, the OpenHarmony cross-compile check and the - # tun packet-routing e2e. The ArkTS unit tests and the HAP build are not - # covered — they need the DevEco command-line tools, which are not publicly - # downloadable (see docs/hos-emulator-vpn.md); run them locally with - # `hvigorw test` / `hvigorw assembleHap`. - core: - name: Core tests, e2e tunnels and tun routing - runs-on: ubuntu-latest - # Generous for a cold cache: clippy and the three test-e2e-host.sh steps - # each build shadowsocks-service (different profiles/targets). - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - - - name: Check out the shared Rust core - run: | - # Cargo.toml path-depends on ../../../core/... relative to - # native/sslocal-ffi, which is a *sibling* of this checkout — the - # layout this subproject has inside shadowsocks-android. - core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" - mkdir -p "$core_dir" - git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ - https://github.com/shadowsocks/shadowsocks-rust.git \ - "$core_dir/shadowsocks-rust" - - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-unknown-linux-ohos - components: clippy, rustfmt - - # C cross-compiler for the OpenHarmony check below: the OHOS native SDK - # is not publicly downloadable, so native/ohos-cc-wrapper.sh shims the C - # bits (blake3) through `zig cc` against the equivalent musl target. - - uses: mlugg/setup-zig@v2 - - - name: Cargo cache - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - native/sslocal-ffi/target - key: cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} - restore-keys: cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- - - - name: rustfmt - working-directory: native/sslocal-ffi - run: cargo fmt --all -- --check - - # --no-deps: shadowsocks-rust is a path dependency and would otherwise be - # linted too, failing on upstream warnings this repo cannot fix. - - name: clippy - working-directory: native/sslocal-ffi - run: cargo clippy --no-deps --all-targets -- -D warnings - - # test-e2e-host.sh skips the tun e2e when the device is absent. In CI - # that must be a hard failure instead: it is the only test that drives - # real IP packets through the tun stack. - - name: Assert the runner can create tun devices - run: test -c /dev/net/tun - - - name: Core verification (unit + e2e + cross-compile + tun routing) - run: ./test-e2e-host.sh diff --git a/.github/workflows/harmonyos.yml b/.github/workflows/harmonyos-build.yml similarity index 52% rename from .github/workflows/harmonyos.yml rename to .github/workflows/harmonyos-build.yml index f70d1c2..549f330 100644 --- a/.github/workflows/harmonyos.yml +++ b/.github/workflows/harmonyos-build.yml @@ -1,38 +1,15 @@ -name: HarmonyOS build and on-device e2e - -# The HarmonyOS side of the project needs Huawei's DevEco command-line tools, -# which are behind an account + region gate (docs/hos-emulator-vpn.md §4) and -# cannot be redistributed — so the build job takes them from a private S3/R2 -# bucket populated by ci/package-hos-toolchain.sh, authenticating with the -# repository secrets R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. -# Secrets are not exposed to pull requests from forks, which is why ci.yml -# (host-side Rust, no SDK needed) stays the gate for every PR. -# -# Two jobs, because they need different machines: -# -# build everything that does not need a running device — the HAP -# build, debug signing and the ArkTS unit tests. Runs on a -# GitHub-hosted macOS runner on every push to main. -# emulator-e2e the on-device suites against a booted emulator. **Cannot run -# on a GitHub-hosted runner**: the Emulator binary is arm64-only -# and the image is phone_all_arm, so it needs an Apple-silicon -# host exposing HVF — GitHub's Apple-silicon runners do not -# support nested virtualization, and their Intel runners (which -# do have HVF) cannot execute an arm64 emulator at all. It -# therefore targets a self-hosted Apple-silicon runner and only -# runs when one is configured. -# -# To enable the e2e job, register a self-hosted runner on an Apple-silicon Mac -# that has the tools and the emulator image installed, label it `harmonyos`, -# and set the repository variables: -# -# HOS_SELF_HOSTED = true -# HOS_TOOLS_PATH = /path/to/command-line-tools -# HOS_IMAGES_PATH = /path/to/image root (the parent of system-image/) +name: HarmonyOS build + +# The HAP build, debug signing and the ArkTS unit tests — everything that needs +# the HarmonyOS SDK but no running device. # -# Without them the job is skipped, so pushes are never left queued against an -# offline runner. `ci/hos-emulator-e2e.sh` is the same entry point either way, -# so the e2e can always be run by hand on any Mac that has both. +# Huawei's DevEco command-line tools are behind an account + region gate +# (docs/hos-emulator-vpn.md §4) and cannot be redistributed, so they are +# streamed from a private S3/R2 bucket populated by +# ci/package-hos-toolchain.sh, authenticated with the repository secrets +# R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. Secrets are not +# exposed to pull requests from forks, which is why this runs on pushes to +# main and on demand, while the host-side workflows gate every PR. on: push: branches: [main] @@ -47,8 +24,6 @@ concurrency: cancel-in-progress: true env: - # Keep in step with ci.yml — both build the same shared core. - SHADOWSOCKS_RUST_REF: v1.23.4 # Where ci/package-hos-toolchain.sh put the bundle. The account lives in the # R2_ENDPOINT secret, so a bucket name on its own gives nothing away. R2_BUCKET: shadowsocks @@ -63,16 +38,9 @@ jobs: if: github.repository == 'shadowsocks/shadowsocks-ohos' steps: - uses: actions/checkout@v4 - - - name: Check out the shared Rust core - run: | - # Cargo.toml path-depends on ../../../core/... relative to - # native/sslocal-ffi, a *sibling* of this checkout. - core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" - mkdir -p "$core_dir" - git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ - https://github.com/shadowsocks/shadowsocks-rust.git \ - "$core_dir/shadowsocks-rust" + - uses: ./.github/actions/rust-core + with: + targets: aarch64-unknown-linux-ohos # ~14 GB free on the runner; the toolchain alone is ~6 GB unpacked. Keep # the *selected* Xcode — the Rust build links through it. @@ -92,20 +60,6 @@ jobs: command -v aws >/dev/null || brew install awscli command -v zstd >/dev/null || brew install zstd - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-unknown-linux-ohos - - - name: Cargo cache - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - native/sslocal-ffi/target - key: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} - restore-keys: hos-cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- - # 352 bytes, so this runs even on a cache hit: the archive's sha256 from # the manifest is the cache key, which means re-uploading a bundle # invalidates the cache by itself, with nothing to bump by hand. @@ -131,7 +85,7 @@ jobs: # Streamed straight into place: holding the archive *and* its expanded # contents at once would not fit. Only the tools are fetched — the 2 GB - # emulator image is for the self-hosted job, which has it locally. + # emulator image is for the self-hosted e2e runner, which has it locally. - name: Fetch the HarmonyOS toolchain if: steps.tools-cache.outputs.cache-hit != 'true' env: @@ -173,7 +127,8 @@ jobs: restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- - name: Resolve ohpm dependencies - run: $HOS_TOOLS/bin/ohpm install + run: | + "$HOS_TOOLS/bin/ohpm" install - name: Build the native core run: native/build-ohos.sh @@ -203,50 +158,3 @@ jobs: name: haps path: entry/build/default/outputs/*/*-signed.hap if-no-files-found: error - - emulator-e2e: - name: On-device e2e on the HarmonyOS emulator - # Apple silicon with HVF; see the header for why this cannot be hosted. - runs-on: [self-hosted, macOS, ARM64, harmonyos] - timeout-minutes: 90 - if: vars.HOS_SELF_HOSTED == 'true' - steps: - - uses: actions/checkout@v4 - - # Kept between runs (the runner's workspace parent persists), so cargo's - # target dir survives and ssserver below is usually a no-op rebuild. - - name: Check out the shared Rust core - run: | - set -euo pipefail - core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" - mkdir -p "$core_dir" - if [[ -d "$core_dir/shadowsocks-rust/.git" ]]; then - git -C "$core_dir/shadowsocks-rust" fetch --depth 1 origin \ - "refs/tags/$SHADOWSOCKS_RUST_REF:refs/tags/$SHADOWSOCKS_RUST_REF" -f - git -C "$core_dir/shadowsocks-rust" checkout -f "$SHADOWSOCKS_RUST_REF" - else - git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ - https://github.com/shadowsocks/shadowsocks-rust.git \ - "$core_dir/shadowsocks-rust" - fi - - # The runner keeps the toolchain, the emulator image and cargo's cache - # locally, so there is nothing to download and nothing to free here. - - name: Build ssserver for the host side of the tunnel - run: | - core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" - cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver - echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" - - - name: On-device e2e - env: - HOS_TOOLS: ${{ vars.HOS_TOOLS_PATH }} - HOS_IMAGES: ${{ vars.HOS_IMAGES_PATH }} - # Its own instance, so a developer's emulator on the same machine is - # left alone. - HOS_INSTANCE: ss_ci - run: ci/hos-emulator-e2e.sh - - - name: Emulator log - if: failure() - run: tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/.github/workflows/harmonyos-e2e.yml b/.github/workflows/harmonyos-e2e.yml new file mode 100644 index 0000000..31813b1 --- /dev/null +++ b/.github/workflows/harmonyos-e2e.yml @@ -0,0 +1,68 @@ +name: HarmonyOS on-device e2e + +# The ohosTest suites against a booted HarmonyOS emulator, driven by +# ci/hos-emulator-e2e.sh (build → sign → boot → unlock → install → run). +# +# **Cannot run on a GitHub-hosted runner**: the Emulator binary is arm64-only +# and the image is phone_all_arm, so it needs an Apple-silicon host exposing +# HVF — GitHub's Apple-silicon runners do not support nested virtualization, +# and their Intel runners (which do have HVF) cannot execute an arm64 emulator +# at all. It therefore targets a self-hosted Apple-silicon runner. +# +# To enable it, register a self-hosted runner on an Apple-silicon Mac that has +# the tools and the emulator image installed, label it `harmonyos`, and set the +# repository variables: +# +# HOS_SELF_HOSTED = true +# HOS_TOOLS_PATH = /path/to/command-line-tools +# HOS_IMAGES_PATH = /path/to/image root (the parent of system-image/) +# +# Without them the job is skipped, so pushes are never left queued against an +# offline runner. ci/hos-emulator-e2e.sh is the same entry point either way, so +# the e2e can always be run by hand on any Mac that has both. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + emulator-e2e: + name: On-device e2e on the HarmonyOS emulator + runs-on: [self-hosted, macOS, ARM64, harmonyos] + timeout-minutes: 90 + if: vars.HOS_SELF_HOSTED == 'true' + steps: + - uses: actions/checkout@v4 + # Keeps its checkout and cargo cache between runs, so ssserver below is + # usually a no-op rebuild. + - uses: ./.github/actions/rust-core + + - name: Build ssserver for the host side of the tunnel + run: | + core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" + cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver + echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" + + - name: On-device e2e + env: + HOS_TOOLS: ${{ vars.HOS_TOOLS_PATH }} + HOS_IMAGES: ${{ vars.HOS_IMAGES_PATH }} + # Its own instance, so a developer's emulator on the same machine is + # left alone. + HOS_INSTANCE: ss_ci + run: ci/hos-emulator-e2e.sh + + - name: Emulator log + if: failure() + run: tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9da3980 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,47 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + rust: + name: rustfmt and clippy + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + with: + components: clippy, rustfmt + + - name: rustfmt + working-directory: native/sslocal-ffi + run: cargo fmt --all -- --check + + # --no-deps: shadowsocks-rust is a path dependency and would otherwise be + # linted too, failing on upstream warnings this repo cannot fix. + - name: clippy + working-directory: native/sslocal-ffi + run: cargo clippy --no-deps --all-targets -- -D warnings + + shell: + name: shellcheck + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + # The build, signing, e2e and packaging entry points are all shell. + - run: shellcheck test-e2e-host.sh native/*.sh ci/*.sh diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml new file mode 100644 index 0000000..c3afc95 --- /dev/null +++ b/.github/workflows/test-core.yml @@ -0,0 +1,31 @@ +name: Core tests + +# The Rust suite: unit tests plus tests/e2e.rs and tests/e2e_plugin.rs, which +# drive sslocal through the same C ABI the NAPI bridge uses and round-trip +# SOCKS5 through an in-process shadowsocks server. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Unit tests and host e2e tunnels + runs-on: ubuntu-latest + # Generous for a cold cache: this builds shadowsocks-service from scratch. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + - run: ./test-e2e-host.sh tests diff --git a/.github/workflows/test-cross.yml b/.github/workflows/test-cross.yml new file mode 100644 index 0000000..7a08e01 --- /dev/null +++ b/.github/workflows/test-cross.yml @@ -0,0 +1,37 @@ +name: OpenHarmony cross-compile + +# Proves the whole core still compiles for the device target. Cheap insurance +# against a change that only builds on the host. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: cargo check for aarch64-unknown-linux-ohos + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + with: + targets: aarch64-unknown-linux-ohos + + # C cross-compiler for the check below: the OHOS native SDK is not + # publicly downloadable, so native/ohos-cc-wrapper.sh shims the C bits + # (blake3) through `zig cc` against the equivalent musl target. + - uses: mlugg/setup-zig@v2 + + - run: ./test-e2e-host.sh cross diff --git a/.github/workflows/test-tun.yml b/.github/workflows/test-tun.yml new file mode 100644 index 0000000..8664335 --- /dev/null +++ b/.github/workflows/test-tun.yml @@ -0,0 +1,35 @@ +name: Tun routing e2e + +# The only test that drives real IP packets through the tun stack: a TCP flow +# into a tun device, asserted to round-trip through the tunnel. Needs Linux, +# /dev/net/tun and CAP_NET_ADMIN, which a GitHub Linux runner has natively. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + tun: + name: Tun packet routing + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + + # test-e2e-host.sh skips the tun e2e when the prerequisites are missing. + # Here that must be a hard failure instead of a silent pass. + - run: ./test-e2e-host.sh tun + env: + TUN_E2E_REQUIRED: '1' diff --git a/AGENTS.md b/AGENTS.md index bceba46..1684ed0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,9 @@ Steps (order matters — the CMake build fails if the staticlib is missing): ## Testing -- **`./test-e2e-host.sh`** — host-side verification, no HarmonyOS SDK needed: +- **`./test-e2e-host.sh [tests|cross|tun ...]`** — host-side verification, no + HarmonyOS SDK needed. All three steps by default, or any subset by name (CI + runs one per workflow, so this stays the single definition of each): 1. `cargo test` in `native/sslocal-ffi` (includes `tests/e2e.rs`: a genuine end-to-end SOCKS5 round-trip through an in-process shadowsocks server, driving sslocal through the same C ABI the NAPI layer uses). @@ -215,34 +217,45 @@ Steps (order matters — the CMake build fails if the staticlib is missing): passwordless sudo (the script escalates itself); on macOS run it via `native/run-tun-e2e-docker.sh` (Docker + cargo-zigbuild), which builds the helper for the host architecture so the container runs it natively. -- **CI** — `.github/workflows/ci.yml` runs rustfmt, clippy and all three - `test-e2e-host.sh` steps (including the tun e2e, which a GitHub Linux - runner can do natively) on every push and pull request. Because the Rust - crate path-depends on the shared `shadowsocks-rust` checkout *outside* this - repository, the workflow clones it to the sibling path - `../core/src/main/rust/shadowsocks-rust` at the ref in `SHADOWSOCKS_RUST_REF` - — keep that in step with shadowsocks-android's submodule pin. - `.github/workflows/harmonyos.yml` covers the rest, for pushes to `main` and - on demand, in two jobs: `build` (HAP build, debug signing, ArkTS unit tests) - on a GitHub-hosted macOS runner, and `emulator-e2e` (the on-device suites on - a booted emulator) on a **self-hosted** Apple-silicon runner labelled - `harmonyos`. The e2e cannot be hosted — the Emulator binary and the image are - both arm64, so it needs HVF, which GitHub's Apple-silicon runners do not - expose (no nested virtualization) and whose Intel runners cannot run an arm64 - emulator at all. That job is skipped unless the repository variables - `HOS_SELF_HOSTED=true`, `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so - pushes never queue against an offline runner. Huawei's DevEco command-line - tools are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor - redistributable, so `build` streams them from a private S3/R2 bucket, using - the secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT`; the S3 - keypair is derived from them at runtime by `ci/r2-env.sh` (token ID from - `/tokens/verify`, secret = SHA-256 of the token value) and masked. Only the - 352-byte manifest is fetched on a normal run: the unpacked toolchain is - cached under the archive's sha256 from that manifest, so a re-uploaded bundle - invalidates the cache by itself. +- **CI** — one workflow per surface, so a failure names what broke instead of + pointing at one big job: + - `lint.yml` — rustfmt, clippy (`--no-deps`; the path-dependency would + otherwise be linted too) and shellcheck over every script. + - `test-core.yml` — `./test-e2e-host.sh tests`. + - `test-cross.yml` — `./test-e2e-host.sh cross` (with `mlugg/setup-zig`). + - `test-tun.yml` — `./test-e2e-host.sh tun` with `TUN_E2E_REQUIRED=1`, so a + missing `/dev/net/tun` fails the job instead of skipping quietly. + - `harmonyos-build.yml` — HAP build, debug signing and the ArkTS unit tests + on a hosted macOS runner. + - `harmonyos-e2e.yml` — the on-device suites, self-hosted (see below). + + The first four gate every push and pull request. Shared setup — the sibling + `shadowsocks-rust` checkout (this crate path-depends on it), the Rust + toolchain and the cargo cache — lives in the composite action + `.github/actions/rust-core`, whose `ref` input is the single place the core's + pin is defined; keep it in step with shadowsocks-android's submodule. + + The two HarmonyOS workflows need Huawei's DevEco command-line tools, which + are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor + redistributable, so `harmonyos-build.yml` streams them from a private S3/R2 + bucket using the secrets `R2_API_TOKEN` (a Cloudflare API token) and + `R2_ENDPOINT`; the S3 keypair is derived from them at runtime by + `ci/r2-env.sh` (token ID from `/tokens/verify`, secret = SHA-256 of the token + value) and masked. Only the 352-byte manifest is fetched on a normal run: the + unpacked toolchain is cached under the archive's sha256 from that manifest, + so a re-uploaded bundle invalidates the cache by itself. `ci/package-hos-toolchain.sh` builds and uploads that bundle from a Mac that - has them installed, taking the same two variables. Secrets are unavailable to fork pull requests, which is - why `ci.yml` remains the gate for every PR. + has the tools installed, taking the same two variables. + + `harmonyos-e2e.yml` runs `ci/hos-emulator-e2e.sh` on a **self-hosted** + Apple-silicon runner labelled `harmonyos`. It cannot be hosted — the Emulator + binary and the image are both arm64, so it needs HVF, which GitHub's + Apple-silicon runners do not expose (no nested virtualization) and whose + Intel runners cannot run an arm64 emulator at all. It is skipped unless the + repository variables `HOS_SELF_HOSTED=true`, `HOS_TOOLS_PATH` and + `HOS_IMAGES_PATH` are set, so pushes never queue against an offline runner. + Secrets are unavailable to fork pull requests, which is why the host-side + workflows remain the gate for every PR. - **ArkTS unit tests** — `entry/src/test` (hypium): `ss://` URL parsing, both SOCKS and tun config serialization (including ACL injection), subscription body parsing. Run from DevEco Studio or headless: diff --git a/README.md b/README.md index 09f8bc2..f44c957 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Shadowsocks for HarmonyOS NEXT -[![CI](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/ci.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/ci.yml) +[![Lint](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/lint.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/lint.yml) +[![Core tests](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-core.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-core.yml) +[![Cross-compile](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml) +[![Tun e2e](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml) +[![HarmonyOS build](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml) A native HarmonyOS NEXT (ArkTS/ArkUI, Stage model) client, sharing the Rust core (`shadowsocks-rust`) with the Android app through a C ABI + NAPI bridge. @@ -75,20 +79,23 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator ## Testing -* **Host e2e (no HarmonyOS SDK needed)** — `./test-e2e-host.sh`: - 1. Rust test suite, including `tests/e2e.rs`: an in-process shadowsocks - server, an sslocal instance driven through the same C ABI the NAPI bridge - uses, and a SOCKS5 round-trip through the encrypted tunnel. - 2. Cross-compile check that the whole core builds for +* **Host e2e (no HarmonyOS SDK needed)** — `./test-e2e-host.sh`, which runs + three independent steps; pass names to run a subset (`./test-e2e-host.sh + cross tun`): + 1. `tests` — Rust test suite, including `tests/e2e.rs`: an in-process + shadowsocks server, an sslocal instance driven through the same C ABI the + NAPI bridge uses, and a SOCKS5 round-trip through the encrypted tunnel. + 2. `cross` — cross-compile check that the whole core builds for `aarch64-unknown-linux-ohos` (real SDK clang if present, else a zig cc shim for the C bits). - 3. **Tun packet-routing e2e** (Linux, `/dev/net/tun`, root or passwordless - sudo): sends a real TCP flow into a tun device and asserts it round-trips - through the tunnel. On non-Linux hosts run it in a privileged container - with `native/run-tun-e2e-docker.sh`. - - All three steps, plus rustfmt and clippy, run in CI on every push and pull - request — see `.github/workflows/ci.yml`. + 3. `tun` — **tun packet-routing e2e** (Linux, `/dev/net/tun`, root or + passwordless sudo): sends a real TCP flow into a tun device and asserts it + round-trips through the tunnel. On non-Linux hosts run it in a privileged + container with `native/run-tun-e2e-docker.sh`. + + Each step is also its own CI workflow, so a red badge names the surface that + broke: `test-core.yml`, `test-cross.yml`, `test-tun.yml`, plus `lint.yml` for + rustfmt/clippy/shellcheck. All four gate every pull request. * **ArkTS unit tests** — `entry/src/test` (hypium) covers `ss://` URL parsing and both SOCKS and tun config serialization; run from DevEco Studio. * **On-device e2e (emulator)** — `ci/hos-emulator-e2e.sh` builds and debug-signs @@ -107,25 +114,38 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator `VpnE2e.test.ets` is skipped here — the public emulator image never delivers guest traffic to `vpn-tun`, so it is a real-device test (see `docs/hos-emulator-vpn.md` §2a). -* **CI** — `ci.yml` runs the host-side suite on every push and pull request. - `harmonyos.yml` adds the parts that need the SDK, on pushes to `main` and on - demand. Because Huawei's toolchain cannot be downloaded by a runner or - redistributed, it is streamed from a private bucket populated by - `ci/package-hos-toolchain.sh`, authenticated with the repository secrets - `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT` — R2's S3 API takes - that token as its ID plus the SHA-256 of its value, which `ci/r2-env.sh` - derives at runtime. The unpacked toolchain is cached between runs, keyed on - the archive's checksum from the bundle manifest, so re-uploading a bundle - invalidates it on its own and nothing has to be bumped by hand. Two jobs: - * `build` — HAP build, debug signing and the ArkTS unit tests, on a - GitHub-hosted macOS runner. - * `emulator-e2e` — the on-device suites, on a **self-hosted** Apple-silicon - runner labelled `harmonyos`. It cannot be hosted: the emulator is an - arm64-only binary running an arm64 guest, so it needs HVF, and GitHub's - Apple-silicon runners have no nested virtualization while their Intel - runners cannot execute it at all. The job is skipped unless the repository - variables `HOS_SELF_HOSTED`, `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are - set, so pushes are never left queued against an offline runner. +* **CI** — one workflow per surface, so a failure names what broke: + + | workflow | what it runs | where | + |---|---|---| + | `lint.yml` | rustfmt, clippy, shellcheck | hosted Linux | + | `test-core.yml` | Rust unit tests + host e2e tunnels | hosted Linux | + | `test-cross.yml` | `aarch64-unknown-linux-ohos` build check | hosted Linux | + | `test-tun.yml` | tun packet-routing e2e | hosted Linux | + | `harmonyos-build.yml` | HAP build, signing, ArkTS unit tests | hosted macOS | + | `harmonyos-e2e.yml` | on-device suites on the emulator | self-hosted macOS | + + The first four gate every pull request. The HarmonyOS pair needs the DevEco + toolchain, which cannot be downloaded by a runner or redistributed, so it is + streamed from a private bucket populated by `ci/package-hos-toolchain.sh` and + authenticated with the repository secrets `R2_API_TOKEN` (a Cloudflare API + token) and `R2_ENDPOINT` — R2's S3 API takes that token as its ID plus the + SHA-256 of its value, which `ci/r2-env.sh` derives at runtime. The unpacked + toolchain is cached between runs, keyed on the archive's checksum from the + bundle manifest, so re-uploading a bundle invalidates it on its own. Since + secrets are not exposed to fork pull requests, those two run on pushes to + `main` and on demand. + + `harmonyos-e2e.yml` cannot be hosted: the emulator is an arm64-only binary + running an arm64 guest, so it needs HVF, and GitHub's Apple-silicon runners + have no nested virtualization while their Intel runners cannot execute it at + all. It is skipped unless the repository variables `HOS_SELF_HOSTED`, + `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so pushes are never left + queued against an offline runner. + + Common setup — the shared `shadowsocks-rust` checkout, the toolchain and the + cargo cache — lives in the composite action `.github/actions/rust-core`, + which is also where the core's pinned ref is defined. ## Tun mode diff --git a/docs/hos-emulator-vpn.md b/docs/hos-emulator-vpn.md index 0893902..665989c 100644 --- a/docs/hos-emulator-vpn.md +++ b/docs/hos-emulator-vpn.md @@ -181,8 +181,8 @@ Because §2a rules out any tun-based test here, the on-device test CI runs is mode through the same NAPI entry point the app uses and pulls a marker page through the tunnel, with a companion spec asserting the marker is unreachable without it. `ci/hos-emulator-e2e.sh` drives the whole thing (build, sign, boot, -unlock, install, run) and is what the `emulator-e2e` job of -`.github/workflows/harmonyos.yml` invokes — on a self-hosted Apple-silicon +unlock, install, run) and is what `.github/workflows/harmonyos-e2e.yml` +invokes — on a self-hosted Apple-silicon runner, since GitHub's hosted macOS runners cannot run this emulator (their Apple-silicon machines have no nested virtualization, and their Intel ones cannot execute an arm64 emulator binary at all). diff --git a/native/tun-e2e-linux.sh b/native/tun-e2e-linux.sh index 2b77cc8..bd6ca91 100755 --- a/native/tun-e2e-linux.sh +++ b/native/tun-e2e-linux.sh @@ -24,6 +24,7 @@ MESSAGE="hello-through-the-tun-packet-router" [[ -x "$HELPER" ]] || { echo "helper binary not found/executable: $HELPER" >&2; exit 1; } PIDS=() +# shellcheck disable=SC2329 # invoked by the EXIT trap below cleanup() { for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done ip netns pids ssns 2>/dev/null | xargs -r kill 2>/dev/null || true diff --git a/test-e2e-host.sh b/test-e2e-host.sh index d76b9f2..fcb6a35 100755 --- a/test-e2e-host.sh +++ b/test-e2e-host.sh @@ -1,79 +1,118 @@ #!/usr/bin/env bash # -# Host-side verification of the HarmonyOS NEXT subproject's native core: +# Host-side verification of the HarmonyOS NEXT subproject's native core. Three +# independent steps, run all together by default or one at a time by name: # -# 1. cargo test — includes tests/e2e.rs, a genuine end-to-end tunnel test: -# an in-process shadowsocks server, an sslocal instance driven through -# the same C ABI the NAPI layer uses, and a SOCKS5 round-trip through the -# encrypted tunnel to an echo server. -# 2. cargo check --target aarch64-unknown-linux-ohos — proves the whole core -# (shadowsocks-service + FFI) compiles for OpenHarmony. Uses the real OHOS -# SDK clang when OHOS_NDK_HOME is set, otherwise falls back to a zig-based -# compile-only shim for the C bits (blake3). +# tests cargo test — includes tests/e2e.rs, a genuine end-to-end tunnel +# test: an in-process shadowsocks server, an sslocal instance driven +# through the same C ABI the NAPI layer uses, and a SOCKS5 round-trip +# through the encrypted tunnel to an echo server. +# cross cargo check --target aarch64-unknown-linux-ohos — proves the whole +# core (shadowsocks-service + FFI) compiles for OpenHarmony. Uses the +# real OHOS SDK clang when OHOS_NDK_HOME is set, otherwise falls back +# to a zig-based compile-only shim for the C bits (blake3). +# tun a real TCP flow into a tun device, asserted to round-trip through +# the tunnel. Needs Linux, /dev/net/tun and CAP_NET_ADMIN. +# +# Usage: +# ./test-e2e-host.sh # all three +# ./test-e2e-host.sh tests # just the Rust suite +# ./test-e2e-host.sh cross tun # any subset, in the order given +# +# CI runs each step as its own workflow (.github/workflows/test-*.yml) so a +# failure names the surface that broke; this script stays the single place that +# defines how each one runs. # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR/native/sslocal-ffi" +CRATE_DIR="$SCRIPT_DIR/native/sslocal-ffi" + +STEPS=("$@") +[[ ${#STEPS[@]} -gt 0 ]] || STEPS=(tests cross tun) -echo "=== 1/3 host tests (incl. SOCKS5 e2e round-trip) ===" -cargo test +step_tests() { + echo "=== host tests (incl. SOCKS5 e2e round-trip) ===" + cd "$CRATE_DIR" + cargo test +} -echo "=== 2/3 cross-compile check for aarch64-unknown-linux-ohos ===" -if ! rustup target list --installed | grep -q aarch64-unknown-linux-ohos; then - rustup target add aarch64-unknown-linux-ohos -fi -# Prefer the real OpenHarmony SDK clang; fall back to a zig cc shim for the C -# bits (blake3) when the SDK is absent. -if [[ -z "${OHOS_NDK_HOME:-}" ]]; then - for candidate in \ - "$HOME/Downloads/command-line-tools/sdk/default/openharmony/native" \ - "$HOME/command-line-tools/sdk/default/openharmony/native"; do - [[ -d "$candidate" ]] && OHOS_NDK_HOME="$candidate" && break - done -fi -if [[ -n "${OHOS_NDK_HOME:-}" ]]; then - export CC_aarch64_unknown_linux_ohos="$OHOS_NDK_HOME/llvm/bin/aarch64-unknown-linux-ohos-clang" -elif command -v zig >/dev/null; then - export CC_aarch64_unknown_linux_ohos="$SCRIPT_DIR/native/ohos-cc-wrapper.sh" -else - echo "Neither OHOS_NDK_HOME nor zig available; skipping C shim" >&2 -fi -cargo check --target aarch64-unknown-linux-ohos +step_cross() { + echo "=== cross-compile check for aarch64-unknown-linux-ohos ===" + cd "$CRATE_DIR" + if ! rustup target list --installed | grep -q aarch64-unknown-linux-ohos; then + rustup target add aarch64-unknown-linux-ohos + fi + # Prefer the real OpenHarmony SDK clang; fall back to a zig cc shim for the + # C bits (blake3) when the SDK is absent. + if [[ -z "${OHOS_NDK_HOME:-}" ]]; then + for candidate in \ + "$HOME/Downloads/command-line-tools/sdk/default/openharmony/native" \ + "$HOME/command-line-tools/sdk/default/openharmony/native"; do + [[ -d "$candidate" ]] && OHOS_NDK_HOME="$candidate" && break + done + fi + if [[ -n "${OHOS_NDK_HOME:-}" ]]; then + export CC_aarch64_unknown_linux_ohos="$OHOS_NDK_HOME/llvm/bin/aarch64-unknown-linux-ohos-clang" + elif command -v zig >/dev/null; then + export CC_aarch64_unknown_linux_ohos="$SCRIPT_DIR/native/ohos-cc-wrapper.sh" + else + echo "Neither OHOS_NDK_HOME nor zig available; skipping C shim" >&2 + fi + cargo check --target aarch64-unknown-linux-ohos +} -echo "=== 3/3 tun packet-routing e2e ===" -# Genuine IP-packet round-trip through the tun stack. Needs Linux, iproute2, -# /dev/net/tun and CAP_NET_ADMIN — root, or passwordless sudo (which is what -# CI runners and most Linux dev boxes have). On other hosts, run it in a -# container with harmony/native/run-tun-e2e-docker.sh instead. -AS_ROOT=() -if [[ "$(uname -s)" != "Linux" ]]; then - TUN_SKIP="not Linux" -elif ! command -v ip >/dev/null; then - TUN_SKIP="iproute2 (ip) not installed" -elif [[ ! -c /dev/net/tun ]]; then - TUN_SKIP="/dev/net/tun is missing" -elif [[ "$(id -u)" == "0" ]]; then - TUN_SKIP="" -elif command -v sudo >/dev/null && sudo -n true 2>/dev/null; then - TUN_SKIP="" - AS_ROOT=(sudo) -else - TUN_SKIP="needs root or passwordless sudo" -fi +step_tun() { + echo "=== tun packet-routing e2e ===" + # Genuine IP-packet round-trip through the tun stack. Needs Linux, + # iproute2, /dev/net/tun and CAP_NET_ADMIN — root, or passwordless sudo + # (which is what CI runners and most Linux dev boxes have). On other hosts, + # run it in a container with native/run-tun-e2e-docker.sh instead. + local as_root=() + local skip="" + if [[ "$(uname -s)" != "Linux" ]]; then + skip="not Linux" + elif ! command -v ip >/dev/null; then + skip="iproute2 (ip) not installed" + elif [[ ! -c /dev/net/tun ]]; then + skip="/dev/net/tun is missing" + elif [[ "$(id -u)" == "0" ]]; then + skip="" + elif command -v sudo >/dev/null && sudo -n true 2>/dev/null; then + as_root=(sudo) + else + skip="needs root or passwordless sudo" + fi + + # A skip must not look like a pass where the environment can support the + # test: CI sets TUN_E2E_REQUIRED=1 so a missing prerequisite fails the job. + if [[ -n "$skip" ]]; then + if [[ -n "${TUN_E2E_REQUIRED:-}" ]]; then + echo "tun e2e cannot run ($skip) but TUN_E2E_REQUIRED is set" >&2 + return 1 + fi + echo "SKIPPED ($skip); use run-tun-e2e-docker.sh on other hosts" + return 0 + fi -if [[ -z "${TUN_SKIP:-}" ]]; then # build the helper as the invoking user, run the namespace setup as root + cd "$CRATE_DIR" cargo build --release --example net_helper - "${AS_ROOT[@]+"${AS_ROOT[@]}"}" env \ - NET_HELPER="$SCRIPT_DIR/native/sslocal-ffi/target/release/examples/net_helper" \ + "${as_root[@]+"${as_root[@]}"}" env \ + NET_HELPER="$CRATE_DIR/target/release/examples/net_helper" \ RUST_LOG="${RUST_LOG:-info}" \ bash "$SCRIPT_DIR/native/tun-e2e-linux.sh" -else - echo "SKIPPED ($TUN_SKIP); use run-tun-e2e-docker.sh on other hosts" -fi +} + +for step in "${STEPS[@]}"; do + case "$step" in + tests|cross|tun) "step_$step" ;; + *) echo "unknown step '$step' (want: tests, cross, tun)" >&2; exit 2 ;; + esac + echo "" +done -echo "" echo "========================================" echo " HarmonyOS core verification PASSED" +echo " steps: ${STEPS[*]}" echo "========================================" From f5f3fb67b40fc9c2c4717183165412dbdd025632 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 14:57:04 +0800 Subject: [PATCH 6/7] Keep shellcheck green across runner shellcheck versions The lint job failed on a false positive my local shellcheck reports under a different code: the EXIT-trap cleanup in tun-e2e-linux.sh is SC2329 (function never invoked) on the newer version and SC2317 (command unreachable) on the one in the runner image, so the disable comment covered only half of it. Disable both, and lint at -S warning so a future image bump adding info-level checks cannot turn CI red on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/lint.yml | 5 ++++- native/tun-e2e-linux.sh | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9da3980..beddfdc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,4 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 # The build, signing, e2e and packaging entry points are all shell. - - run: shellcheck test-e2e-host.sh native/*.sh ci/*.sh + # -S warning: which info-level checks exist varies with the shellcheck + # version in the runner image, so an image bump must not turn CI red on + # its own. + - run: shellcheck -S warning test-e2e-host.sh native/*.sh ci/*.sh diff --git a/native/tun-e2e-linux.sh b/native/tun-e2e-linux.sh index bd6ca91..3f4cffd 100755 --- a/native/tun-e2e-linux.sh +++ b/native/tun-e2e-linux.sh @@ -24,7 +24,10 @@ MESSAGE="hello-through-the-tun-packet-router" [[ -x "$HELPER" ]] || { echo "helper binary not found/executable: $HELPER" >&2; exit 1; } PIDS=() -# shellcheck disable=SC2329 # invoked by the EXIT trap below +# Invoked by the EXIT trap below, which shellcheck cannot see: older versions +# call the body unreachable (SC2317), newer ones call the function unused +# (SC2329). +# shellcheck disable=SC2317,SC2329 cleanup() { for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done ip netns pids ssns 2>/dev/null | xargs -r kill 2>/dev/null || true From b03bf9386205008afebae57de61178d06153bee1 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sat, 25 Jul 2026 16:48:36 +0800 Subject: [PATCH 7/7] Run the HAP build on Linux, keep the ArkTS tests on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Huawei ships the command-line tools for Linux x64, so the build job no longer needs a macOS runner: a Linux runner has roughly twice the free disk, needs no Xcode eviction to fit the SDK, and starts faster. Only the emulator is macOS/Windows-only, and that already lives in the self-hosted workflow. Validated in an Ubuntu VM (Rosetta-backed, since the tools are x86_64): ohpm install against the real registry, both HAP builds including the CMake/NAPI native build through the SDK's clang, and debug signing — all pass. Two Linux-specific needs surfaced: restool's libimage_transcoder_shared.so links against libGL, so libgl1 is installed before @CompileResource; signing wants a JDK, which the runner image ships. The ArkTS unit tests cannot follow. @GenerateUnitTestResult is not a reporting step — it is what runs the specs, by driving the SDK's previewer, a GUI component. On Linux it throws "Cannot read properties of null" in a container and hangs indefinitely in a full VM with the amd64 GL libraries present; either way the specs never execute, and a deliberately failing spec produced no output at all. They move to harmonyos-unit-tests.yml on a hosted macOS runner, with a guard that fails the job if the test task did not complete, so a silent non-execution cannot read as a pass. The Linux toolchain is stored as Huawei's zip verbatim: it contains 19 paths differing only in case (linux/netfilter headers), which a case-insensitive filesystem silently collapses, so it must never be repacked on macOS. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/harmonyos-build.yml | 57 ++++----- .github/workflows/harmonyos-unit-tests.yml | 132 +++++++++++++++++++++ AGENTS.md | 35 ++++-- README.md | 35 ++++-- ci/package-hos-toolchain.sh | 19 ++- ci/r2-env.sh | 7 +- 6 files changed, 223 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/harmonyos-unit-tests.yml diff --git a/.github/workflows/harmonyos-build.yml b/.github/workflows/harmonyos-build.yml index 549f330..f441899 100644 --- a/.github/workflows/harmonyos-build.yml +++ b/.github/workflows/harmonyos-build.yml @@ -1,7 +1,9 @@ name: HarmonyOS build -# The HAP build, debug signing and the ArkTS unit tests — everything that needs -# the HarmonyOS SDK but no running device. +# The HAP build and debug signing — what needs the HarmonyOS SDK but neither a +# device nor a GUI. The ArkTS unit tests are *not* here: their runner drives the +# previewer, a GUI component that does not work on Linux (the specs silently do +# not execute), so they live in harmonyos-unit-tests.yml on macOS. # # Huawei's DevEco command-line tools are behind an account + region gate # (docs/hos-emulator-vpn.md §4) and cannot be redistributed, so they are @@ -33,7 +35,7 @@ env: jobs: build: name: HAP build, signing and ArkTS unit tests - runs-on: macos-15 + runs-on: ubuntu-latest timeout-minutes: 60 if: github.repository == 'shadowsocks/shadowsocks-ohos' steps: @@ -42,24 +44,6 @@ jobs: with: targets: aarch64-unknown-linux-ohos - # ~14 GB free on the runner; the toolchain alone is ~6 GB unpacked. Keep - # the *selected* Xcode — the Rust build links through it. - - name: Free disk space - run: | - df -h / - keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" - for app in /Applications/Xcode_*.app; do - [[ "$app" == "$keep" ]] || sudo rm -rf "$app" - done - sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ - /Library/Developer/CoreSimulator/Profiles/Runtimes || true - df -h / - - - name: Ensure aws and zstd are present - run: | - command -v aws >/dev/null || brew install awscli - command -v zstd >/dev/null || brew install zstd - # 352 bytes, so this runs even on a cache hit: the archive's sha256 from # the manifest is the cache key, which means re-uploading a bundle # invalidates the cache by itself, with nothing to bump by hand. @@ -72,7 +56,7 @@ jobs: source ci/r2-env.sh sha="$(aws s3 cp --endpoint-url "$R2_ENDPOINT" \ "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/manifest.txt" - \ - | awk '/hos-tools\.tar\.zst/ { print $1 }')" + | awk '/hos-tools-linux-x64\.zip/ { print $1 }')" [[ -n "$sha" ]] || { echo "no hos-tools checksum in the manifest"; exit 1; } echo "HOS_TOOLS_SHA=$sha" >> "$GITHUB_ENV" @@ -83,9 +67,12 @@ jobs: path: ~/hos-tools key: hos-tools-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }} - # Streamed straight into place: holding the archive *and* its expanded - # contents at once would not fit. Only the tools are fetched — the 2 GB - # emulator image is for the self-hosted e2e runner, which has it locally. + # Huawei's zip verbatim, because it holds 19 pairs of paths that differ + # only in case (linux/netfilter headers) — repacking it on a + # case-insensitive filesystem silently drops files. It cannot be streamed + # either: unzip needs to seek, so it lands on disk and is deleted after. + # Only the tools are fetched; the emulator image belongs to the + # self-hosted e2e runner, which has it locally. - name: Fetch the HarmonyOS toolchain if: steps.tools-cache.outputs.cache-hit != 'true' env: @@ -96,8 +83,10 @@ jobs: source ci/r2-env.sh mkdir -p "$HOME/hos-tools" aws s3 cp --endpoint-url "$R2_ENDPOINT" \ - "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ - | zstd -dc | tar -C "$HOME/hos-tools" -xf - + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools-linux-x64.zip" \ + /tmp/hos-tools.zip + unzip -q /tmp/hos-tools.zip -d "$HOME/hos-tools" + rm -f /tmp/hos-tools.zip - name: Locate the toolchain run: | @@ -126,6 +115,11 @@ jobs: key: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}-${{ hashFiles('oh-package-lock.json5', 'oh-package.json5', 'entry/oh-package.json5') }} restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- + # restool's libimage_transcoder_shared.so links against libGL, which the + # runner image does not ship — @CompileResource dies without it. + - name: Install the resource compiler's dependency + run: sudo apt-get update -qq && sudo apt-get install -y -qq libgl1 + - name: Resolve ohpm dependencies run: | "$HOS_TOOLS/bin/ohpm" install @@ -143,15 +137,6 @@ jobs: native/sign-hap-debug.sh entry/build/default/outputs/default/entry-default-unsigned.hap native/sign-hap-debug.sh entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap - # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in - # " — so the failure has to be grepped out of the log. - - name: ArkTS unit tests - run: | - set -o pipefail - "$HOS_TOOLS/bin/hvigorw" --no-daemon test --mode module \ - -p module=entry -p product=default 2>&1 | tee unit-tests.log - ! grep -q "ERROR: Error in" unit-tests.log - - name: Upload the signed HAPs uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/harmonyos-unit-tests.yml b/.github/workflows/harmonyos-unit-tests.yml new file mode 100644 index 0000000..36b5d2f --- /dev/null +++ b/.github/workflows/harmonyos-unit-tests.yml @@ -0,0 +1,132 @@ +name: ArkTS unit tests + +# `hvigorw test` on a hosted macOS runner. +# +# **Not on Linux**, even though harmonyos-build.yml builds there happily: the +# task that produces the results (@GenerateUnitTestResult) is what actually +# *runs* the specs, by driving the SDK's previewer — a GUI component. On Linux +# it dies with "Cannot read properties of null" in a container, and hangs +# forever in a full VM with the amd64 GL libraries installed; either way the +# specs never execute, and a deliberately failing spec produced no output at +# all. So this job takes the macOS toolchain bundle instead. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + R2_BUCKET: shadowsocks + HOS_BUNDLE_PREFIX: harmonyos-6.1.1 + CARGO_TERM_COLOR: always + +jobs: + unit-tests: + name: ArkTS unit tests + runs-on: macos-15 + timeout-minutes: 45 + if: github.repository == 'shadowsocks/shadowsocks-ohos' + steps: + - uses: actions/checkout@v4 + + # ~14 GB free; the toolchain alone is ~6 GB unpacked. Keep the *selected* + # Xcode — nothing else here needs the others. + - name: Free disk space + run: | + df -h / + keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" + for app in /Applications/Xcode_*.app; do + [[ "$app" == "$keep" ]] || sudo rm -rf "$app" + done + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ + /Library/Developer/CoreSimulator/Profiles/Runtimes || true + df -h / + + - name: Ensure aws and zstd are present + run: | + command -v aws >/dev/null || brew install awscli + command -v zstd >/dev/null || brew install zstd + + # 352 bytes, so this runs even on a cache hit: the archive's sha256 from + # the manifest is the cache key, so re-uploading a bundle invalidates the + # cache by itself. + - name: Resolve the bundle version + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + sha="$(aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/manifest.txt" - \ + | awk '/hos-tools\.tar\.zst/ { print $1 }')" + [[ -n "$sha" ]] || { echo "no hos-tools checksum in the manifest"; exit 1; } + echo "HOS_TOOLS_SHA=$sha" >> "$GITHUB_ENV" + + - name: Cache the HarmonyOS toolchain + id: tools-cache + uses: actions/cache@v4 + with: + path: ~/hos-tools + key: hos-tools-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }} + + # The macOS bundle, streamed straight into place: holding the archive and + # its expanded contents at once would not fit. + - name: Fetch the HarmonyOS toolchain + if: steps.tools-cache.outputs.cache-hit != 'true' + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + mkdir -p "$HOME/hos-tools" + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ + | zstd -dc | tar -C "$HOME/hos-tools" -xf - + + - name: Locate the toolchain + run: | + set -euo pipefail + tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + [[ -x "$tools/bin/hvigorw" ]] || { echo "no hvigorw under $tools"; exit 1; } + { + echo "HOS_TOOLS=$tools" + echo "DEVECO_SDK_HOME=$tools/sdk" + echo "OHOS_SDK_HOME=$tools/sdk/default/openharmony" + } >> "$GITHUB_ENV" + + - name: Cache ohpm and hvigor dependencies + uses: actions/cache@v4 + with: + path: | + ~/.ohpm + ~/.hvigor + oh_modules + .hvigor + key: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}-${{ hashFiles('oh-package-lock.json5', 'oh-package.json5', 'entry/oh-package.json5') }} + restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- + + - name: Resolve ohpm dependencies + run: | + "$HOS_TOOLS/bin/ohpm" install + + # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in + # " — so the failure has to be grepped out of the log. The second + # grep is the guard against the opposite mistake: if the run produced no + # test task at all, an absent failure line would look like a pass. + - name: ArkTS unit tests + run: | + set -o pipefail + "$HOS_TOOLS/bin/hvigorw" --no-daemon test --mode module \ + -p module=entry -p product=default 2>&1 | tee unit-tests.log + grep -q "Finished :entry:default@GenerateUnitTestResult" unit-tests.log \ + || { echo "the test task never completed"; exit 1; } + ! grep -q "ERROR: Error in" unit-tests.log diff --git a/AGENTS.md b/AGENTS.md index 1684ed0..28b0301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,8 +225,11 @@ Steps (order matters — the CMake build fails if the staticlib is missing): - `test-cross.yml` — `./test-e2e-host.sh cross` (with `mlugg/setup-zig`). - `test-tun.yml` — `./test-e2e-host.sh tun` with `TUN_E2E_REQUIRED=1`, so a missing `/dev/net/tun` fails the job instead of skipping quietly. - - `harmonyos-build.yml` — HAP build, debug signing and the ArkTS unit tests - on a hosted macOS runner. + - `harmonyos-build.yml` — HAP build and debug signing, on a hosted **Linux** + runner (Huawei ships linux-x64 command-line tools). + - `harmonyos-unit-tests.yml` — the ArkTS unit tests, on a hosted **macOS** + runner: their runner drives the SDK previewer, which on Linux throws in a + container and hangs in a VM, with specs never executing. - `harmonyos-e2e.yml` — the on-device suites, self-hosted (see below). The first four gate every push and pull request. Shared setup — the sibling @@ -235,17 +238,25 @@ Steps (order matters — the CMake build fails if the staticlib is missing): `.github/actions/rust-core`, whose `ref` input is the single place the core's pin is defined; keep it in step with shadowsocks-android's submodule. - The two HarmonyOS workflows need Huawei's DevEco command-line tools, which + The three HarmonyOS workflows need Huawei's DevEco command-line tools, which are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor - redistributable, so `harmonyos-build.yml` streams them from a private S3/R2 - bucket using the secrets `R2_API_TOKEN` (a Cloudflare API token) and - `R2_ENDPOINT`; the S3 keypair is derived from them at runtime by - `ci/r2-env.sh` (token ID from `/tokens/verify`, secret = SHA-256 of the token - value) and masked. Only the 352-byte manifest is fetched on a normal run: the - unpacked toolchain is cached under the archive's sha256 from that manifest, - so a re-uploaded bundle invalidates the cache by itself. - `ci/package-hos-toolchain.sh` builds and uploads that bundle from a Mac that - has the tools installed, taking the same two variables. + redistributable, so they stream them from a private S3/R2 bucket using the + secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT`; the S3 + keypair is derived from them at runtime by `ci/r2-env.sh` (token ID from + `/tokens/verify`, secret = SHA-256 of the token value) and masked. Two + toolchains live there: `hos-tools-linux-x64.zip` (Huawei's Linux zip, + verbatim — it holds 19 paths differing only in case, so repacking it on a + case-insensitive filesystem drops files) for the build job, and + `hos-tools.tar.zst` (macOS) for the unit tests. Only the 352-byte manifest is + fetched on a normal run: the unpacked toolchain is cached under that + archive's sha256 from the manifest, so a re-upload invalidates the cache by + itself. `ci/package-hos-toolchain.sh` builds and uploads the macOS bundle and + the emulator image; the Linux zip is uploaded as-is. + + Linux quirks the build job handles: `restool`'s + `libimage_transcoder_shared.so` links against libGL, so `libgl1` is installed + before `@CompileResource` runs; signing needs a JDK, which the runner image + ships. `harmonyos-e2e.yml` runs `ci/hos-emulator-e2e.sh` on a **self-hosted** Apple-silicon runner labelled `harmonyos`. It cannot be hosted — the Emulator diff --git a/README.md b/README.md index f44c957..2ca3af8 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Cross-compile](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml) [![Tun e2e](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml) [![HarmonyOS build](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml) +[![ArkTS unit tests](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-unit-tests.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-unit-tests.yml) A native HarmonyOS NEXT (ArkTS/ArkUI, Stage model) client, sharing the Rust core (`shadowsocks-rust`) with the Android app through a C ABI + NAPI bridge. @@ -97,7 +98,10 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator broke: `test-core.yml`, `test-cross.yml`, `test-tun.yml`, plus `lint.yml` for rustfmt/clippy/shellcheck. All four gate every pull request. * **ArkTS unit tests** — `entry/src/test` (hypium) covers `ss://` URL parsing - and both SOCKS and tun config serialization; run from DevEco Studio. + and both SOCKS and tun config serialization. Run from DevEco Studio, or + headless with `hvigorw test --mode module -p module=entry -p product=default` + — **on macOS or Windows**: the runner drives the SDK's previewer, which does + not work on Linux. * **On-device e2e (emulator)** — `ci/hos-emulator-e2e.sh` builds and debug-signs both HAPs, boots the HarmonyOS emulator, installs them and runs `entry/src/ohosTest` against a shadowsocks server on the host: @@ -122,10 +126,11 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator | `test-core.yml` | Rust unit tests + host e2e tunnels | hosted Linux | | `test-cross.yml` | `aarch64-unknown-linux-ohos` build check | hosted Linux | | `test-tun.yml` | tun packet-routing e2e | hosted Linux | - | `harmonyos-build.yml` | HAP build, signing, ArkTS unit tests | hosted macOS | + | `harmonyos-build.yml` | HAP build and debug signing | hosted Linux | + | `harmonyos-unit-tests.yml` | ArkTS unit tests | hosted macOS | | `harmonyos-e2e.yml` | on-device suites on the emulator | self-hosted macOS | - The first four gate every pull request. The HarmonyOS pair needs the DevEco + The first four gate every pull request. The HarmonyOS trio needs the DevEco toolchain, which cannot be downloaded by a runner or redistributed, so it is streamed from a private bucket populated by `ci/package-hos-toolchain.sh` and authenticated with the repository secrets `R2_API_TOKEN` (a Cloudflare API @@ -133,15 +138,25 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator SHA-256 of its value, which `ci/r2-env.sh` derives at runtime. The unpacked toolchain is cached between runs, keyed on the archive's checksum from the bundle manifest, so re-uploading a bundle invalidates it on its own. Since - secrets are not exposed to fork pull requests, those two run on pushes to + secrets are not exposed to fork pull requests, those three run on pushes to `main` and on demand. - `harmonyos-e2e.yml` cannot be hosted: the emulator is an arm64-only binary - running an arm64 guest, so it needs HVF, and GitHub's Apple-silicon runners - have no nested virtualization while their Intel runners cannot execute it at - all. It is skipped unless the repository variables `HOS_SELF_HOSTED`, - `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so pushes are never left - queued against an offline runner. + Huawei ships the command-line tools for Linux x64 as well, so the HAP build + runs on a Linux runner — more free disk, no Xcode eviction, faster start. Two + things keep the Mac in the picture: + + * The **ArkTS unit tests** cannot run on Linux. The task that emits the + results is what actually executes the specs, by driving the SDK's previewer + (a GUI component); on Linux it either throws or hangs, and a deliberately + failing spec produces no output at all. So `harmonyos-unit-tests.yml` uses + the macOS bundle. + * The **emulator** is macOS/Windows-only, and its binary and guest are both + arm64, so it needs HVF — which GitHub's Apple-silicon runners do not expose + (no nested virtualization) and whose Intel runners cannot use for an arm64 + guest. `harmonyos-e2e.yml` therefore targets a self-hosted Apple-silicon + runner, and is skipped unless the repository variables `HOS_SELF_HOSTED`, + `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so pushes are never left + queued against an offline runner. Common setup — the shared `shadowsocks-rust` checkout, the toolchain and the cargo cache — lives in the composite action `.github/actions/rust-core`, diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh index 2183b73..5a09908 100755 --- a/ci/package-hos-toolchain.sh +++ b/ci/package-hos-toolchain.sh @@ -21,14 +21,27 @@ # Produces (under $OUT, default ./hos-bundle) and uploads to # s3://$R2_BUCKET/$PREFIX/: # -# hos-tools.tar.zst command-line-tools, minus what a CLI build never uses. -# This is what the `build` job of harmonyos.yml streams. +# hos-tools.tar.zst the **macOS** command-line-tools, minus what a CLI +# build never uses. Used by harmonyos-unit-tests.yml +# (the ArkTS test runner only works on macOS). # hos-images.tar.zst the emulator system image. CI does not fetch this — # the emulator only runs on a self-hosted Apple-silicon # runner, which has the image locally; it is here to # provision such a machine (or a replacement) without # going through Huawei's region-gated download again. -# manifest.txt versions and sha256 of both, for the workflow to pin +# manifest.txt versions and sha256 of every archive; the workflows +# key their toolchain cache on the relevant one. +# +# The **Linux** toolchain is not built here. Upload Huawei's zip verbatim: +# +# aws s3 cp --endpoint-url "$R2_ENDPOINT" --checksum-algorithm CRC32 \ +# commandline-tools-linux-x64-.zip \ +# "s3://$R2_BUCKET//hos-tools-linux-x64.zip" +# +# then add its sha256 to manifest.txt as ` hos-tools-linux-x64.zip`. +# It must not be repacked on macOS: it holds 19 pairs of paths differing only +# in case (linux/netfilter headers), which a case-insensitive filesystem +# silently collapses. # # Set NO_UPLOAD=1 to only build the archives locally. # diff --git a/ci/r2-env.sh b/ci/r2-env.sh index bc8d7e2..e5b4d40 100644 --- a/ci/r2-env.sh +++ b/ci/r2-env.sh @@ -18,7 +18,12 @@ if [[ -n "${R2_API_TOKEN:-}" && -z "${AWS_ACCESS_KEY_ID:-}" ]]; then "https://api.cloudflare.com/client/v4/accounts/$_r2_account/tokens/verify" \ -H "Authorization: Bearer $R2_API_TOKEN" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" - AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + # sha256sum on Linux, shasum on macOS — this runs on both. + if command -v sha256sum >/dev/null; then + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | sha256sum | cut -d' ' -f1)" + else + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + fi export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY unset _r2_account # The ID is not secret by itself, but it is half a credential.