Add HarmonyOS CI: HAP build + ArkTS tests hosted, on-device e2e self-hosted - #5
Merged
Conversation
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 <ms>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Everything past the C ABI — the HAP build, the ArkTS layer, the NAPI bridge on a real HarmonyOS runtime — was outside CI, because Huawei's DevEco command-line tools and emulator image sit behind an account + region gate a runner cannot pass (
docs/hos-emulator-vpn.md§4) and cannot be redistributed.Two jobs, because they need different machines
.github/workflows/harmonyos.yml:build— GitHub-hostedmacos-15. Streams the toolchain from a private R2 bucket, builds the native core, builds and debug-signs both HAPs, runs the ArkTS unit tests (not previously in CI either), publishes the signed HAPs as artifacts.emulator-e2e— self-hosted Apple-silicon runner labelledharmonyos. This cannot be hosted: GitHub's Apple-silicon runners have no nested virtualization, and their Intel runners — which do have HVF — cannot execute an arm64 emulator binary running an arm64 guest. Skipped unless the repository variablesHOS_SELF_HOSTED,HOS_TOOLS_PATHandHOS_IMAGES_PATHare set, so pushes are never left queued against an offline runner.ci.yml(host-side Rust, no SDK) stays the gate for every PR, since secrets are not exposed to fork PRs.Credentials and caching
Two secrets,
R2_API_TOKEN(a Cloudflare API token) andR2_ENDPOINT. R2's S3 API accepts a Cloudflare token as its ID plus the SHA-256 of its value, soci/r2-env.shderives the keypair at runtime and masks both halves; rotating means replacing one token, with no stored keypair to chase. The same helper is sourced by the packaging script, so upload and download take identical inputs.The unpacked toolchain is cached and keyed on the archive's sha256 from the bundle manifest, so a normal run downloads only that 352-byte manifest, and re-uploading a bundle invalidates the cache on its own — nothing to bump by hand. ohpm's store and the hvigor plugins are cached alongside.
Scripts
ci/hos-emulator-e2e.sh— builds the core, builds and debug-signs both HAPs, starts a host-sidessserver+ marker page, boots and unlocks the emulator, installs, runs the suites. Also the local entry point on any Mac with the tools:HOS_TOOLS=… HOS_IMAGES=… ci/hos-emulator-e2e.sh(SKIP_EMULATOR=1reuses a running emulator,KEEP_EMULATOR=1leaves it up).ci/package-hos-toolchain.sh— packs and uploads the bundle: tools 1.5 GB, image 2.0 GB compressed.codelinteris dropped; the previewers cannot be — hvigor validates the SDK component list and fails with "SDK component missing". CI fetches only the tools; the image archive exists to provision a self-hosted runner without going through the region-gated download again.ci/r2-env.sh— the shared credential derivation described above.The new on-device e2e
VpnE2e.test.etscannot pass on the public emulator image: no guest traffic ever reachesvpn-tun(§2a). SoSocksE2e.test.etsdrives the tunnel the other way — the core is started in SOCKS mode through the same NAPI entry point the app uses, and fetches a marker page at127.0.0.1:18800. That literal is resolved on the far side of the tunnel by the host'sssserver; inside the guest it is the guest's own loopback, where nothing listens. A successful fetch therefore cannot be anything but a tunnelled one, and a second spec asserts the guest cannot reach the marker directly — without which a request escaping through slirp would look identical to success.Two findings, now documented
Hypium.setTimeConfig()is not a timeout setter. It installs a system-time provider that hypium later calls.getRealTime()on while reporting a finished spec; handing it a number throws inside the reporter, and the run hangs after the spec body completes — process alive, no log, no faultlog. The real knob is-s timeout <ms>onaa test. The call is removed fromVpnE2e.test.etstoo, and the wrong advice corrected in the docs.aa testwill not launch anything ("cannot be unlocked automatically" in developer mode). The script wakes it, stops it dimming, and swipes.Also: closing a
TCPSocketwhoseconnect()is still in flight kills the test process outright — noted in the docs and avoided in the test.Verification
The e2e was run locally against the 6.1.1 emulator, including twice from a fresh instance created by the script and once from the packaged bundle extracted to a clean path:
with the host-side
ssserverloggingestablished tcp tunnel … <-> 127.0.0.1:18800for the run. ArkTS unit tests pass, and a deliberately failing spec confirmed the CI grep catches them (hvigor exits 0 either way). The credential derivation and the manifest-based cache key were both exercised against the live bucket. shellcheck clean.Not yet verified: the
buildjob has never run —workflow_dispatch404s until the workflow file is on the default branch. Worth dispatching right after merge; that first run also fills the cache.State of the setup
s3://shadowsocks/harmonyos-6.1.1/, sizes verified against the local copy.R2_API_TOKENandR2_ENDPOINTset on the repository.HOS_*variables are set; until then the e2e is a manualci/hos-emulator-e2e.shrun.🤖 Generated with Claude Code