Skip to content

OHOS: cross-compile with NDK + sign + verify in dockerharmony (1.6.58.6) - #13

Merged
ronaldtse merged 21 commits into
mainfrom
fix/ohos-ndk-cmake
Jul 26, 2026
Merged

OHOS: cross-compile with NDK + sign + verify in dockerharmony (1.6.58.6)#13
ronaldtse merged 21 commits into
mainfrom
fix/ohos-ndk-cmake

Conversation

@ronaldtse

@ronaldtse ronaldtse commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Closes #14.

Replace the 1.6.58.4/.5 "Alpine-built bytes labeled as OHOS" approach with a proper OHOS NDK cross-compile. The shipped .so is now built with Huawei's official ohos.toolchain.cmake, statically linked against zlib built with the same toolchain, code-signed with binary-sign-tool, and empirically verified in real OHOS userland (dockerharmony).

Supersedes #12 (closed; shipped Alpine bytes with dockerharmony verification but no NDK build) and #11 (closed; 5 CI iterations failed because of a hand-written toolchain.cmake instead of NDK's bundled one).

Why

The 1.6.58.4/.5 OHOS gems shipped Alpine-built musl arm64 bytes under the OHOS platform label, based on an unverified assumption that "same dynamic linker path = byte-compatible". Real risks flagged: musl symbol versions differ, -fvisibility=hidden defaults differ, TLS/pthread layout may differ.

PR #11 attempted to fix this via OHOS NDK cross-compile but used a hand-written toolchain.cmake with system CMake. After 5 CI iterations it still couldn't get past try_compile/sysroot/zlib-detection issues.

PR #12 added dockerharmony verification of the Alpine bytes — proving they load in OHOS userland, but not actually fixing the underlying "wrong ABI" concern.

This PR (#13) does it properly:

  • Uses NDK's bundled ohos.toolchain.cmake (the official Huawei CMake integration, which handles sysroot + clang target + try_compile).
  • Builds zlib statically with the same toolchain (OHOS sysroot ships zlib as libshared_libz.z.so, not libz.so — static linking sidesteps this).
  • Signs the .so with binary-sign-tool sign -selfSign 1 (mandatory for OHOS runtime loading).
  • Verifies in dockerharmony natively on the arm64 runner (no qemu for the verification phase).

How

Architecture

New lib/libpng/ohos/ namespace with MECE classes:

  • OHOS::NDK — pure-data class for NDK path discovery. No shell calls except via explicit #download.
  • OHOS::ZlibBuilder < MiniPortileCMake — static zlib build with the OHOS toolchain.
  • OHOS::CodeSigner — wraps binary-sign-tool. Pure-data #sign_command is testable without invoking the tool.
  • OHOS::Recipe < Libpng::Recipe — orchestrates: ensure NDK → build zlib → configure libpng with toolchain + static zlib → build → sign.

Libpng::Recipe.for_target(platform) factory (OCP seam): returns OHOS::Recipe for *-ohos, base Recipe otherwise. The existing recipe is unchanged except for this factory method.

CI topology

Single ubuntu-24.04-arm job (the OHOS NDK clang is x86_64 ELF). On the arm64 host we install qemu-user-static + binfmt-support and register binfmt for x86_64 — NDK binaries (clang, binary-sign-tool) then run transparently via binfmt. dockerharmony verification runs natively because the arm64 rootfs matches the host arch.

Why arm64 + binfmt instead of x86_64 + arm64-emulated docker:

  • Native arm64 docker is rock-solid; arm64 emulation on x86_64 via binfmt has been flaky in CI historically.
  • Only NDK tool invocations pay the qemu tax (~5x slowdown on ~1 min of compile = ~5 min, acceptable).
  • dockerharmony smoke-test runs at native speed.

Files added

Files modified

  • lib/libpng.rb — add autoload :OHOS
  • lib/libpng/recipe.rb — add for_target factory class method
  • ext/extconf.rb — use factory
  • .github/workflows/build.yml, release.yml — OHOS matrix entry moved out of build_musl, dedicated build_ohos job added
  • lib/libpng/version.rb — iteration 5 → 6
  • README.adoc, CHANGELOG.md, CLAUDE.md

What this verifies

  • The OHOS NDK toolchain successfully cross-compiles libpng 1.6.58 to aarch64-linux-ohos.
  • Static zlib links cleanly (no libshared_libz.z.so dependency in the output).
  • binary-sign-tool sign -selfSign 1 succeeds on the resulting .so.
  • The signed .so loads in real OHOS userland (dockerharmony) and round-trips a PNG encode/decode with matching bytes.

What this does NOT verify

  • Real OHOS hardware. dockerharmony runs OHOS rootfs on a Linux kernel via qemu binfmt_misc for the build phase; the verification phase runs natively on arm64. Real devices may enforce additional constraints (kernel-level code signing, SELinux policies).
  • Enrollment signing. -selfSign 1 produces a self-signed cert. Production Huawei-managed deployments may require re-signing with an enrolled cert.

Test plan

  • bundle exec rake — 132 existing specs + 49 new OHOS specs pass (181 total)
  • bundle exec rake rubocop clean
  • CI: binfmt_misc registers successfully on ubuntu-24.04-arm
  • CI: NDK downloads successfully (~1.5 GB, cached)
  • CI: Static zlib builds with OHOS toolchain
  • CI: libpng cross-compiles with ohos.toolchain.cmake
  • CI: binary-sign-tool signs the .so
  • CI: dockerharmony smoke-test outputs OK
  • CI: All 11 platform gems still build

Version

Bumps LIBPNG_RUBY_ITERATION 5 → 6. Replaces the regression acknowledged in CHANGELOG (1.6.58.4/.5 used Alpine-built bytes for OHOS without NDK).

ronaldtse added 21 commits July 26, 2026 19:50
12 design documents covering the architecture, NDK setup, zlib build,
recipe factory, code signing, smoke-test, CI workflow, specs, docs,
and PR plan for cross-compiling libpng for OHOS with the official NDK.

Replaces PR #12's Alpine-built bytes labeled OHOS approach with proper
OHOS NDK cross-compile + signing + dockerharmony verification.
Adds the Ruby + shell infrastructure to cross-compile libpng for
aarch64-linux-ohos using Huawei's official OHOS NDK:

- ext/ohos/setup-ndk.sh: idempotent download of OHOS SDK + LLVM-19
  via the daily_build API (pattern adapted from ohos-node/build.sh).
- ext/ohos/smoke-test.c: minimal libpng round-trip test (encode ->
  decode -> byte compare), cross-compiled with NDK clang and run
  inside dockerharmony for empirical OHOS userland verification.
- ext/ohos/verify-prepare.sh: cross-compiles smoke-test with NDK
  clang, bundles the signed .so + SONAME symlinks. Fixes the symlink
  bug from PR #12 where only libpng16.so (dev symlink) was bundled
  but the smoke-test binary's NEEDED entry was libpng16.so.16 (SONAME).

- lib/libpng/ohos.rb: namespace + autoloads for OHOS::NDK,
  OHOS::Recipe, OHOS::ZlibBuilder, OHOS::CodeSigner.
- lib/libpng/ohos/ndk.rb: pure-data class for NDK path discovery
  (toolchain, sysroot, clang, sign-tool). No shell calls except via
  explicit #download (invokes setup-ndk.sh).
- lib/libpng/ohos/zlib_builder.rb: OHOS::ZlibBuilder < MiniPortileCMake.
  Builds zlib statically (libz.a) with the OHOS toolchain; linked into
  libpng16.so to avoid OHOS's non-standard libshared_libz.z.so dependency.
- lib/libpng/ohos/code_signer.rb: OHOS::CodeSigner. Wraps
  binary-sign-tool sign -selfSign 1. Pure-data #sign_command is
  testable without invoking the tool.
- lib/libpng/ohos/recipe.rb: OHOS::Recipe < Libpng::Recipe. Orchestrates
  the OHOS build: download NDK, build static zlib, configure libpng
  with ohos.toolchain.cmake + static zlib, sign the .so post-install.

- lib/libpng/recipe.rb: adds Recipe.for_target(platform) class method
  factory. OCP-compliant seam: returns OHOS::Recipe for *-ohos targets,
  base Recipe otherwise. Existing recipe logic unchanged.
- lib/libpng.rb: autoload :OHOS entry added (lazy-loaded only when
  OHOS target is requested).
- ext/extconf.rb: switches to Recipe.for_target(...).new so the
  source gem (no target_platform) still uses base Recipe.

Zlib is built statically because libpng's CMake find_package(ZLIB)
expects libz.so and OHOS ships zlib under SONAME libshared_libz.z.so.
Static linking sidesteps the whole problem.

Code-signing uses -selfSign 1 (self-signed) which is sufficient for
OHOS userland load. Production Huawei-managed deployments may require
re-signing with an enrolled cert; documented in CHANGELOG caveats.

Architecture decision: run on ubuntu-24.04-arm (native arm64) with
binfmt_misc + qemu-user-static. The NDK clang is x86_64 ELF; on an
arm64 host it runs transparently via binfmt. Verification (dockerharmony)
runs natively because the arm64 rootfs matches the host arch.

49 new specs under spec/ohos/ + spec/recipe_factory_spec.rb covering
NDK path discovery, factory wiring, ZlibBuilder configure_defaults,
CodeSigner command shape, and OHOS::Recipe configure_defaults.
No doubles used (real instances + tmpdir-based fake NDK structure).
Removes the aarch64-linux-ohos entry from build_musl (no longer
Alpine-built) and adds a dedicated build_ohos job in both build.yml
and release.yml. The new job runs on ubuntu-24.04-arm and:

1. Installs qemu-user-static + binfmt-support and registers binfmt
   for x86_64 (NDK binaries are x86_64 ELF).
2. Caches ext/ohos/ndk/ across runs (~1.5 GB; key includes setup-ndk.sh
   hash so cache invalidates when the script changes).
3. Runs setup-ndk.sh if cache miss.
4. Builds the OHOS gem (libpng + static zlib + signing) via rake
   gem:native:aarch64-linux-ohos with target_platform env var set.
5. Runs verify-prepare.sh to cross-compile smoke-test for dockerharmony.
6. Pulls dockerharmony and runs the smoke-test in real OHOS userland.
   Build fails if smoke-test doesn't output OK.

publish job (release.yml) now waits on [bump, build, build_musl,
build_ohos] so the OHOS gem is included in every release.
- lib/libpng/version.rb: LIBPNG_RUBY_ITERATION 5 -> 6.
- CHANGELOG.md: new 1.6.58.6 entry describing the OHOS NDK cross-compile
  (toolchain, static zlib, signing, dockerharmony verify) and listing
  PR #11 and #12 as superseded.
- README.adoc: aarch64-linux-ohos row now reflects the actual build host
  (ubuntu-24.04-arm + OHOS NDK, verified in dockerharmony) instead of
  the misleading "Alpine, byte-compatible with musl".
- CLAUDE.md: file table extended with lib/libpng/ohos/ + ext/ohos/;
  Common commands section now has an "OHOS build notes" subsection
  explaining the binfmt_misc + qemu-user-static setup and that local
  builds require arm64 + binfmt registration.
Run 1 failed: "cd: can't cd to ext/ohos/ndk/ohos-sdk/linux".

The ohos-sdk-public tarball extracts to ./ohos-sdk/{linux,windows,ohos}/
at the top level (per ohos-node/build.sh). Extracting into
$PREFIX/ohos-sdk created a nested $PREFIX/ohos-sdk/ohos-sdk/linux/.

Fix: extract straight into $PREFIX so the path lands at
$PREFIX/ohos-sdk/linux/ as expected. Same pattern for LLVM-19.
Run 2 failed because OHOS::NDK#exist? returned false even though
setup-ndk.sh reported "NDK ready". Likely my hardcoded paths
(ohos-sdk/linux/{build/cmake/ohos.toolchain.cmake,
toolchains/lib/binary-sign-tool}) don't match the actual extracted
layout. The OHOS CMake doc suggests paths include a native/ prefix
(SDK_PATH/native/build/cmake/ohos.toolchain.cmake).

Add a find + ls at the end of setup-ndk.sh to print the actual
locations of ohos.toolchain.cmake, binary-sign-tool, and clang.
Once we see the real layout we can fix the NDK class paths.
Run 3 find output revealed ohos.toolchain.cmake is NOT in the daily_build
ohos-sdk-public tarball (only binary-sign-tool + clang were found). Two
possible causes:

1. The SDK ships native-*.zip separately and ohos-node only unzipped
   toolchains-*.zip. Fix: change the unzip loop from 'toolchains-*.zip'
   to '*.zip' so any native-*.zip gets extracted too.

2. The toolchain file lives under ohos-sdk/linux/native/build/cmake/
   (per the OHOS CMake doc, which uses SDK_PATH/native/build/cmake/).
   Fix: update OHOS::NDK#toolchain_path and #cmake_path to include
   the native/ prefix.

Both fixes are pushed together. If native/ doesn't appear after this,
the find diagnostics will show what's actually there so we can adapt
(fallback: write our own minimal toolchain.cmake or use system clang
with --target=aarch64-linux-ohos).

Specs updated to match the new native/ paths.
Run 4 failed at the very first try_compile with:
  x86_64-binfmt-P: Could not open '/lib64/ld-linux-x86-64.so.2'

Root cause: 'update-binfmts --enable qemu-x86_64' registers qemu as the
handler for x86_64 ELF but WITHOUT the fix-binary (F) flag. Without F,
when the kernel tries to exec an x86_64 DYNAMIC binary, it tries to use
the host's /lib64/ld-linux-x86-64.so.2 interpreter (which doesn't exist
on arm64 Ubuntu) instead of the guest's interpreter embedded by qemu.

Switch to 'docker run --privileged --rm tonistiigi/binfmt --install amd64'
which registers binfmt_misc with the F flag. This embeds qemu into the
kernel's binfmt handlers at registration time, so dynamic x86_64 binaries
run transparently on the arm64 host.

Also strengthen the smoke check: compile + run a static x86_64 hello-world
binary INSIDE an amd64 docker container, then run it on the host file
system. If host binfmt is broken, the binary won't run.

Drop qemu-user-static + binfmt-support from apt install since tonistiigi/binfmt
installs its own qemu binaries.
The smoke check compiled a hello-world inside an alpine:amd64 container
to verify host binfmt was wired up. But apk's gcc package on Alpine
doesn't pull in libc-dev, so stdio.h is missing and the smoke check
itself failed.

Drop the smoke check entirely -- the NDK clang invocation in the next
step is the real test. If binfmt is broken, the build fails clearly
with the same x86_64-binfmt-P error we saw in run 4.
Run 6 still failed with 'qemu-x86_64: Could not open /lib64/ld-linux-x86-64.so.2'
even after switching to tonistiigi/binfmt. The F flag registers qemu as
the handler, but qemu itself still needs to find the x86_64 dynamic
loader to exec dynamic binaries.

tonistiigi/binfmt doesn't install an x86_64 rootfs. Install
libc6-amd64-cross (which provides the loader at
/usr/x86_64-linux-gnu/lib/ld-linux-x86-64.so.2) and symlink it to
/lib64/ld-linux-x86-64.so.2 (the path qemu looks for).
Run 7 got past the loader error but then hit 'cannot open shared object
file: libpthread.so.0'. libc6-amd64-cross only ships libc -- not the
libstdc++/libpthread/libgcc_s the NDK clang needs.

Debian multiarch installs the full x86_64 userland alongside arm64:
dpkg --add-architecture amd64 + apt install libc6:amd64 libstdc++6:amd64
libgcc-s1:amd64. The packages install at /lib/x86_64-linux-gnu/ with the
loader symlinked at /lib64/ld-linux-x86-64.so.2.
Runs 5-8 tried to make the arm64 host run x86_64 NDK binaries directly
via qemu-user-static. Each fix revealed another missing piece:
- update-binfmts lacks F flag -> tonistiigi/binfmt
- qemu needs x86_64 loader -> libc6-amd64-cross symlink
- clang needs libpthread/libstdc++ -> Debian multiarch
- ports.ubuntu.com has no binary-amd64 -> need to add archive.ubuntu.com source

Each step added complexity. Stepping back: docker's binfmt_misc
registration is rock-solid (run 5 confirmed docker emulation works on
this runner). Move the entire build INSIDE an amd64 docker container.
Docker handles the amd64 emulation as a whole, with no host-side library
juggling.

New topology:
- Runner: ubuntu-24.04-arm (arm64 native, satisfies the 'OHOS runs on
  ARM' framing)
- Build: docker run --platform linux/amd64 ruby:3.3 -> NDK clang runs
  natively x86_64 inside the container
- Verify: docker run ghcr.io/hqzing/dockerharmony -> arm64 native (no
  emulation)

Trade-off: ~3-5x slowdown for the build phase (qemu emulates the entire
container), but reliability >> speed here. The build phase takes ~1-2
min natively, so ~5-10 min under emulation is acceptable.

NDK cache key drops the 'arm64' suffix since the cached NDK bytes are
architecture-independent (just downloaded + extracted).
Run 9 got the toolchain + clang working but failed at:
  ohos-sdk/linux/native/sysroot/usr/include/limits.h:6: fatal error:
    'bits/alltypes.h' file not found

The SDK bundles a partial sysroot at ohos-sdk/linux/native/sysroot/
(missing arch-specific musl headers). The LLVM-19 tarball we also
download ships ohos-sysroot.tar.gz with the complete sysroot.

Replace the SDK's partial sysroot with a symlink to the LLVM-19 sysroot
so ohos.toolchain.cmake (which resolves CMAKE_SYSROOT relative to its
own location) picks up the complete headers.

Add diagnostics to confirm the symlink took and that alltypes.h is now
reachable from both sysroot paths.
Run 10 diagnostic showed llvm-19/sysroot/usr/include/ doesn't exist.
My 'mkdir sysroot && tar -C sysroot -zxf ohos-sysroot.tar.gz' created a
nested llvm-19/sysroot/sysroot/usr/include/ instead.

ohos-sysroot.tar.gz already contains sysroot/ at the top level (per
ohos-node/build.sh which extracts with no -C). Drop the mkdir + -C and
extract directly into llvm-19/.
Run 11 failed at the SDK download step with:
  curl: (35) Recv failure: Connection reset by peer

cidownload.openharmony.cn sometimes resets connections mid-transfer
(observed on runs 1-10 too, but always succeeded on retry). Add
--retry 5 --retry-delay 10 --retry-all-errors to both the API query
and the tarball downloads so transient network issues don't fail the
build.
Run 12 still failed with 'bits/alltypes.h not found' AND diagnostic
showed llvm-19/sysroot/usr/include/ doesn't exist. My fix (drop the
mkdir + -C sysroot) didn't work, which means ohos-sysroot.tar.gz has
yet another top-level layout.

Add diagnostics:
- ls llvm-19/ (top-level)
- find llvm-19/ -maxdepth 2 -type d (the dir tree)
- find anywhere for alltypes.h or 'bits' dir
- tar -tzf on LLVM-19.tar.gz to see what the inner sysroot tarball contains

This will tell us the actual layout so we can extract correctly.
Run 13 diagnostics revealed the layout mismatch:
- SDK sysroot at ohos-sdk/linux/native/sysroot/usr/include/ uses MULTIARCH
  layout: usr/include/<arch>/bits/alltypes.h
- LLVM-19 sysroot at llvm-19/sysroot/<arch>/usr/include/ uses PER-ARCH
  layout: <arch>/usr/include/bits/alltypes.h

The OHOS toolchain expects per-arch layout (one sysroot per arch with
plain usr/include/bits/ inside). My earlier symlink pointed at the
LLVM-19 multiarch root, which still didn't have usr/include/bits/ at the
expected path.

Fix: point the symlink at llvm-19/sysroot/aarch64-linux-ohos/ specifically
(per-arch subdir). Then ohos-sdk/linux/native/sysroot/usr/include/bits/
alltypes.h resolves correctly to the LLVM-19 per-arch sysroot.
Run 14 got past header errors but linker fails:
  ld.lld: cannot open Scrt1.o
  ld.lld: cannot open crti.o
  ld.lld: unable to find library -lm
  ld.lld: unable to find library -lc

Possible cause: my symlink to llvm-19/sysroot/aarch64-linux-ohos/
points at a sysroot that has headers but no libraries. The original
SDK sysroot (multiarch) had the libraries.

Add diagnostics to see where Scrt1.o, crti.o, libc.so, libm.so actually
live. If they're only in the multiarch SDK sysroot, we may need to merge
both sysroots instead of replacing one with the other.
Run 15 diagnostic showed the symlink wasn't being created correctly:
  setup-ndk: ohos-sdk/linux/native/sysroot resolves to:
  ls: cannot access 'ext/ohos/ndk/ohos-sdk/linux/native/sysroot/usr/lib'

Root cause: $PREFIX was passed as 'ext/ohos/ndk' (relative) via the
workflow's --prefix arg. ln -s interprets relative targets relative to
the SYMLINK's location, not the script's cwd. So the symlink at
ext/ohos/ndk/ohos-sdk/linux/native/sysroot pointed at
ext/ohos/ndk/llvm-19/sysroot/aarch64-linux-ohos -- which from the
symlink's dir resolves to
ext/ohos/ndk/ohos-sdk/linux/native/ext/ohos/ndk/llvm-19/... (dangling).

Fix: resolve PREFIX to an absolute path with $(cd "$PREFIX" && pwd)
right after parsing args. Now ln -s writes an absolute target and the
symlink resolves correctly regardless of cwd.
Run 16 failed at 'cd: can't cd to ext/ohos/ndk' on the new absolute-path
resolution line. The dir doesn't exist yet at that point -- it was being
created later in the script. Move mkdir earlier.
Run 17 diagnostic confirmed the symlink works on the host:
  SDK sysroot is symlink to:
  /home/runner/work/libpng-ruby/libpng-ruby/ext/ohos/ndk/llvm-19/sysroot/aarch64-linux-ohos

But the BUILD runs inside a docker container that mounts the repo at
/work/, not /home/runner/.... The absolute symlink target dangles
inside the container, so clang's --sysroot path appears empty and
ld.lld can't find Scrt1.o, libc.so, etc.

Fix: use a relative symlink target (../../../llvm-19/sysroot/aarch64-
linux-ohos). The relative path from ohos-sdk/linux/native/ to
llvm-19/sysroot/aarch64-linux-ohos is the same regardless of where
the repo is mounted.
@ronaldtse
ronaldtse merged commit cb3a739 into main Jul 26, 2026
41 of 42 checks passed
@ronaldtse
ronaldtse deleted the fix/ohos-ndk-cmake branch July 26, 2026 14:58
@ronaldtse

Copy link
Copy Markdown
Contributor Author

For future porting work, I wrote up the OHOS NDK cross-compile + verification pattern as a public gist:

Porting C libraries and Ruby platform gems to OHOS (OpenHarmony)
https://gist.github.com/ronaldtse/78b6b610cfa00ead8fb3b8f935afaa3b

Covers everything learned across the 18 CI iterations of this PR:

  • NDK architecture (ohos-sdk-public + LLVM-19, what's in each zip)
  • The two-sysroots problem (multiarch vs per-arch layout) and the relative-symlink fix
  • Why you should NEVER hand-write toolchain.cmake (use the NDK's bundled ohos.toolchain.cmake)
  • Static-linking zlib to avoid OHOS's libshared_libz.z.so SONAME mismatch
  • Code signing with binary-sign-tool sign -selfSign 1
  • CI topology options (x86_64 native vs arm64 + amd64 docker)
  • dockerharmony as a real-OHOS-userland test environment
  • OCP factory pattern for Ruby gem integration (Recipe.for_target)
  • Full pitfalls table (18 lessons distilled)

Generalizes to any C library that uses CMake. Reference: this PR.

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.

OHOS (aarch64-linux-ohos): proper NDK cross-compile + signing + dockerharmony verification

1 participant