Skip to content

Add HarmonyOS CI: HAP build + ArkTS tests hosted, on-device e2e self-hosted - #5

Merged
madeye merged 7 commits into
mainfrom
feature/hos-emulator-ci
Jul 25, 2026
Merged

Add HarmonyOS CI: HAP build + ArkTS tests hosted, on-device e2e self-hosted#5
madeye merged 7 commits into
mainfrom
feature/hos-emulator-ci

Conversation

@madeye

@madeye madeye commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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-hosted macos-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-e2eself-hosted Apple-silicon runner labelled harmonyos. 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 variables HOS_SELF_HOSTED, HOS_TOOLS_PATH and HOS_IMAGES_PATH are 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) and R2_ENDPOINT. R2's S3 API accepts a Cloudflare token as its ID plus the SHA-256 of its value, so ci/r2-env.sh derives 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-side ssserver + 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=1 reuses a running emulator, KEEP_EMULATOR=1 leaves it up).
  • ci/package-hos-toolchain.sh — packs and uploads the bundle: tools 1.5 GB, image 2.0 GB compressed. codelinter is 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.ets cannot pass on the public emulator image: no guest traffic ever reaches vpn-tun (§2a). So SocksE2e.test.ets drives 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 at 127.0.0.1:18800. That literal is resolved on the far side of the tunnel by the host's ssserver; 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> on aa test. The call is removed from VpnE2e.test.ets too, and the wrong advice corrected in the docs.
  • A freshly booted image is locked and aa test will not launch anything ("cannot be unlocked automatically" in developer mode). The script wakes it, stops it dimming, and swipes.

Also: closing a TCPSocket whose connect() 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:

OHOS_REPORT_RESULT: stream=Tests run: 5, Failure: 0, Error: 0, Pass: 5, Ignore: 0
  HarmonyOS on-device e2e PASSED

with the host-side ssserver logging established tcp tunnel … <-> 127.0.0.1:18800 for 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 build job has never run — workflow_dispatch 404s 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

  • Bundle uploaded to s3://shadowsocks/harmonyos-6.1.1/, sizes verified against the local copy.
  • Secrets R2_API_TOKEN and R2_ENDPOINT set on the repository.
  • The e2e job stays skipped until a self-hosted runner is registered and the three HOS_* variables are set; until then the e2e is a manual ci/hos-emulator-e2e.sh run.

🤖 Generated with Claude Code

madeye and others added 2 commits July 25, 2026 09:29
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>
@madeye madeye changed the title Run the on-device e2e on the HarmonyOS emulator in CI Add HarmonyOS CI: HAP build + ArkTS tests hosted, on-device e2e self-hosted Jul 25, 2026
madeye and others added 5 commits July 25, 2026 10:13
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>
@madeye
madeye merged commit a358c83 into main Jul 25, 2026
0 of 5 checks passed
@madeye
madeye deleted the feature/hos-emulator-ci branch July 25, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant