diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a4ef606..3260be8 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -117,8 +117,9 @@ jobs: # is safe here for two reasons: (a) the env: block is step-scoped, so # mix release in the next step does not inherit TARGET_ABI, and (b) mix # deps.compile only compiles the named deps, not exqlite, so - # cc_precompiler never runs during this step. mix release sees mdex_native already - # compiled and skips it, bundling the musl .so from _build/prod. + # cc_precompiler never runs during this step. mix release sees + # mdex_native already compiled and skips it, bundling the musl .so + # from _build/prod. # rustler_precompiled is listed first because mix deps.compile only # compiles the named deps (not their transitive deps), and mdex_native # uses it via `use RustlerPrecompiled` at compile time. See EXT-7. @@ -128,6 +129,14 @@ jobs: MIX_ENV: prod TARGET_ABI: musl run: mix deps.compile rustler_precompiled mdex_native + - + # mdex_native's precompiled musl NIF dynamically needs libgcc_s. + # Burrito's musl loader otherwise finds the Ubuntu host's glibc copy + # first and rejects it at runtime. Bundle Alpine's compatible runtime + # under a unique name and patch the NIF to select it deterministically. + if: startsWith(matrix.target, 'linux_') + name: Bundle mdex_native's musl libgcc runtime + run: ../ci/bundle_mdex_musl_libgcc.sh - name: Build the ${{ matrix.target }} target run: MIX_ENV=prod BURRITO_TARGET=${{ matrix.target }} mix release lc @@ -157,19 +166,16 @@ jobs: fi fi - - # Smoke-test the linux_aarch64 binary on the native ARM64 runner: - # proves the binary executes (right architecture), the OTP app boots, - # and all NIFs (exqlite via elixir_make/Zig, mdex_native via - # rustler_precompiled) load correctly. `lc version` runs in - # interactive mode (no LINEAR_CLI_DAEMON=true), starts an empty - # supervisor, prints the version, and exits 0 - no API key or network - # needed. Only runs for linux_aarch64 because that's the only target - # whose runner architecture matches the binary (macos/Windows smoke - # tests are out of scope for CRY-48). - if: matrix.target == 'linux_aarch64' - name: Smoke-test the linux_aarch64 binary boots and NIFs load + # Both Linux targets build on native matching runners. `lc version` + # boots the application and performs a hidden Marcli render, forcing + # mdex_native and its NIF to load before the command can exit 0. No API + # key or network is needed. macOS/Windows runtime smoke tests remain + # out of scope because their hosted runners are not all native to the + # configured Burrito targets. + if: startsWith(matrix.target, 'linux_') + name: Smoke-test the ${{ matrix.target }} binary and Markdown NIF timeout-minutes: 1 - run: ./burrito_out/lc_linux_aarch64 version + run: ./burrito_out/lc_${{ matrix.target }} version - # Handed off to burrito-package below, which needs every target's # binary gathered back into one place before it can build the @@ -351,6 +357,12 @@ jobs: TARGET_ABI: musl run: mix deps.compile rustler_precompiled mdex_native working-directory: app + - + # Apply the same musl libgcc repair as the standalone Linux binaries; + # this release is copied into the Alpine container image below. + name: Bundle mdex_native's musl libgcc runtime + run: ../ci/bundle_mdex_musl_libgcc.sh + working-directory: app - name: Build the linux_x86_64 target (container's payload) run: MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release lc @@ -362,11 +374,10 @@ jobs: run: ./ci/build_image.sh "${{ needs.burrito-package.outputs.tag_name }}" - # Verify the musl NIF actually loads on Alpine before publishing. - # Analogous to the linux_aarch64 native smoke test in burrito-build: - # `lc version` boots the OTP app, loads all NIFs (exqlite via - # elixir_make/Zig, mdex_native via rustler_precompiled with the musl - # NIF bundled by the pre-compile step above), prints the version, and - # exits 0. No API key or network needed. ci/build_image.sh prefers + # `lc version` boots the OTP app and performs a hidden Markdown render, + # forcing mdex_native's NIF and its bundled libgcc runtime to load. + # It then prints the version and exits 0; no API key or network is + # needed. ci/build_image.sh prefers # Podman, so the image lives in Podman's local storage; we run it # directly rather than loading a tarball. LINEAR_CLI_DAEMON is # overridden to false (the image bakes in true so the default CMD diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 9c64efb..120eba4 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -18,9 +18,12 @@ defmodule LinearCli.CLI.Commands do @doc """ Ported from commands/version.rb, extended to respect the global `--output json` option like every other command does - previously - ignored it and always printed plain text. + ignored it and always printed plain text. The hidden Markdown render makes + this command a complete release smoke test for mdex_native's NIF as well as + the application boot path. """ def version(%{options: options}) do + verify_markdown_runtime!() version = to_string(Application.spec(:linear_cli, :vsn)) if options.output == "json" do @@ -32,6 +35,11 @@ defmodule LinearCli.CLI.Commands do :ok end + defp verify_markdown_runtime! do + _rendered = Marcli.render("runtime check", escape_sequences: false) + :ok + end + @doc "Ported from commands/team/list.rb. Ruby's `--mine` defaults true." def team_list(%{flags: flags, options: options}) do result = if flags.no_mine, do: Linear.teams(), else: Linear.my_teams() diff --git a/ci/bundle_mdex_musl_libgcc.sh b/ci/bundle_mdex_musl_libgcc.sh new file mode 100755 index 0000000..cd96efa --- /dev/null +++ b/ci/bundle_mdex_musl_libgcc.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "$script_dir/.." && pwd) +native_dir=${1:-"$repo_root/app/_build/prod/lib/mdex_native/priv/native"} + +if [ ! -d "$native_dir" ]; then + printf 'mdex_native directory does not exist: %s\n' "$native_dir" >&2 + exit 1 +fi + +native_dir=$(cd -- "$native_dir" && pwd) +shopt -s nullglob +musl_nifs=("$native_dir"/libmdex_native_nif-*-unknown-linux-musl.so) +shopt -u nullglob + +if [ "${#musl_nifs[@]}" -ne 1 ]; then + printf 'expected exactly one mdex_native musl NIF in %s; found %d\n' \ + "$native_dir" "${#musl_nifs[@]}" >&2 + exit 1 +fi + +# Rust's dynamically linked musl cdylibs depend on libgcc_s. Burrito starts +# its Linux ERTS with a musl loader but puts the host library directories on +# LD_LIBRARY_PATH, where a glibc libgcc_s may be found first. Give Alpine's +# musl-compatible runtime a unique dependency name and keep it beside the NIF +# so the loader cannot accidentally select the host copy. +nif_name=$(basename -- "${musl_nifs[0]}") +libgcc_name=libmdex_musl_libgcc_s.so.1 + +if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + container_runtime=podman +elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + container_runtime=docker +else + printf 'podman or docker is required to bundle the musl libgcc runtime\n' >&2 + exit 1 +fi + +container_args=(run --rm) + +if [ "$container_runtime" = podman ]; then + container_args+=(--security-opt label=disable) +fi + +container_args+=(-v "$native_dir:/native") + +# The single-quoted body is intentionally expanded by the container's shell. +# shellcheck disable=SC2016 +"$container_runtime" "${container_args[@]}" alpine:3.22 sh -euxc ' + apk add --no-cache libgcc patchelf + + nif="/native/$1" + bundled_libgcc="/native/$2" + install -m 0755 /usr/lib/libgcc_s.so.1 "$bundled_libgcc" + + # Set RUNPATH before growing DT_NEEDED. With patchelf 0.18, doing these + # two mutations in the opposite order can produce a loadable NIF that + # crashes on its first call. + patchelf --set-rpath "\$ORIGIN" "$nif" + + if patchelf --print-needed "$nif" | grep -Fxq libgcc_s.so.1; then + patchelf --replace-needed libgcc_s.so.1 "$2" "$nif" + elif ! patchelf --print-needed "$nif" | grep -Fxq "$2"; then + printf "mdex_native NIF has no expected libgcc dependency: %s\\n" "$nif" >&2 + exit 1 + fi + + patchelf --print-needed "$nif" | grep -Fxq "$2" + test "$(patchelf --print-rpath "$nif")" = "\$ORIGIN" +' sh "$nif_name" "$libgcc_name" + +printf 'bundled musl libgcc runtime for %s\n' "${musl_nifs[0]}"