diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 47e7be2..96ee335 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -64,13 +64,12 @@ jobs: # a release with a target missing. # # Each target runs on its matching OS runner so rustler_precompiled - # (used by mdex_native) downloads the correct precompiled NIF for the - # target platform. Cross-compiling from Ubuntu caused the Linux NIF to - # be bundled into macOS/Windows releases, making MDEx unavailable at - # runtime (see CRY-40). Linux targets also get a pre-compile step that - # forces the musl NIF variant, since Ubuntu runners auto-select the - # glibc NIF but Burrito Linux binaries run on musl-based systems (see - # EXT-7). + # (used by mdex_native and makeup_syntect) downloads the correct + # precompiled NIF for the target platform. Cross-compiling from Ubuntu + # caused Linux NIFs to be bundled into macOS/Windows releases, making + # them unavailable at runtime (see CRY-40). Linux targets additionally + # prepare musl variants, since Ubuntu runners select glibc while Burrito + # Linux binaries run on musl-based systems (see EXT-7). matrix: include: - target: macos_aarch64 @@ -109,35 +108,35 @@ jobs: - run: mix deps.get - - # rustler_precompiled resolves the NIF target at compile time from - # :erlang.system_info(:system_architecture), which is always glibc on - # Ubuntu runners. Burrito bundles its own musl runtime for Linux and - # the container runs Alpine - both need the musl NIF, not glibc. - # TARGET_ABI=musl tells rustler_precompiled to download the musl - # variant. cc_precompiler (used by exqlite) also reads TARGET_ABI, but - # 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. - # 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. if: startsWith(matrix.target, 'linux_') - name: Pre-compile mdex_native with the musl NIF + # makeup_syntect calls its NIF while compiling generated module docs, + # so it cannot compile against a musl artifact on the glibc runner. + # Compile it for the host first; prepare_musl_nifs.sh replaces only + # the packaged NIF after compilation. castore must be named before + # rustler_precompiled 0.8.x because deps.compile does not compile + # transitive dependencies automatically. + name: Compile makeup_syntect with its host NIF + env: + MIX_ENV: prod + run: mix deps.compile castore rustler_precompiled makeup_syntect + - + # rustler_precompiled resolves the NIF target at compile time from the + # Ubuntu host unless TARGET_ABI overrides it. This is safe as a + # step-scoped variable: cc_precompiler (used by exqlite) never sees it. + # mix release then reuses the completed dependency in _build/prod. + if: startsWith(matrix.target, 'linux_') + name: Pre-compile mdex_native with its musl NIF env: MIX_ENV: prod TARGET_ABI: musl - run: mix deps.compile rustler_precompiled mdex_native + run: mix deps.compile 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. + # Replace makeup_syntect's host NIF with its checksummed musl artifact, + # then bundle and select Alpine's compatible libgcc runtime for both + # Rust NIFs. Burrito's musl loader otherwise finds Ubuntu's glibc copy. if: startsWith(matrix.target, 'linux_') - name: Bundle mdex_native's musl libgcc runtime - run: ../ci/bundle_mdex_musl_libgcc.sh + name: Install and repair the musl NIFs + run: ../ci/prepare_musl_nifs.sh - name: Build the ${{ matrix.target }} target run: MIX_ENV=prod BURRITO_TARGET=${{ matrix.target }} mix release lc @@ -168,9 +167,10 @@ jobs: fi - # Both Linux targets build on native matching runners. `lc version` - # boots the application and performs a hidden syntax-highlighted Marcli - # render, forcing mdex_native, its NIF, Makeup, and the Elixir lexer to - # load before the command can exit 0. No API key or network is needed. + # boots the application and performs hidden syntax-highlighted Marcli + # renders for both Elixir and Ruby, forcing mdex_native, makeup_syntect, + # both NIFs, Makeup, and both lexers 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_') @@ -349,21 +349,26 @@ jobs: run: mix deps.get working-directory: app - - # Same reasoning as the burrito-build pre-compile step: Ubuntu runners - # default to the glibc NIF, but the container image runs Alpine (musl). - # rustler_precompiled listed first for the same reason as there. See - # EXT-7. - name: Pre-compile mdex_native with the musl NIF + # Same two-stage preparation as the Linux burrito-build legs: Syntect + # must finish compiling with its host NIF before that artifact can be + # replaced with the musl build. + name: Compile makeup_syntect with its host NIF + env: + MIX_ENV: prod + run: mix deps.compile castore rustler_precompiled makeup_syntect + working-directory: app + - + name: Pre-compile mdex_native with its musl NIF env: MIX_ENV: prod TARGET_ABI: musl - run: mix deps.compile rustler_precompiled mdex_native + run: mix deps.compile 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 + # Install Syntect's musl artifact and apply the same libgcc repair to + # both Rust NIFs as the standalone Linux binaries. + name: Install and repair the musl NIFs + run: ../ci/prepare_musl_nifs.sh working-directory: app - name: Build the linux_x86_64 target (container's payload) @@ -376,10 +381,11 @@ jobs: run: ./ci/build_image.sh "${{ needs.burrito-package.outputs.tag_name }}" - # Verify the musl NIF actually loads on Alpine before publishing. - # `lc version` boots the OTP app and performs a hidden syntax-highlighted - # Markdown render, forcing mdex_native's NIF, its bundled libgcc runtime, - # Makeup, and the Elixir lexer to load. It then prints the version and - # exits 0; no API key or network is needed. ci/build_image.sh prefers + # `lc version` boots the OTP app and performs hidden syntax-highlighted + # Markdown renders, forcing the MDEx and Syntect NIFs, their bundled + # libgcc runtimes, Makeup, and the Elixir/Ruby lexers 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 2c12e2d..daf8985 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -20,9 +20,9 @@ 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. The hidden Markdown render makes - this command a complete release smoke test for mdex_native's NIF, Marcli's - syntax-highlighting lexer, and the application boot path. + ignored it and always printed plain text. The hidden Markdown renders make + this command a complete release smoke test for the MDEx and Syntect NIFs, + Marcli's syntax-highlighting integration, and the application boot path. """ def version(%{options: options}) do verify_markdown_runtime!() @@ -39,10 +39,13 @@ defmodule LinearCli.CLI.Commands do defp verify_markdown_runtime! do theme = Marcli.Theme.default() - rendered = Marcli.render("```elixir\ndef smoke, do: :ok\n```") - highlighted_keyword = theme.syntax.keyword_declaration <> "def" <> theme.reset + elixir = Marcli.render("```elixir\ndef smoke, do: :ok\n```") + ruby = Marcli.render("```ruby\ndef smoke; :ok; end\n```") - unless String.contains?(rendered, highlighted_keyword) do + elixir_keyword = theme.syntax.keyword_declaration <> "def" <> theme.reset + ruby_keyword = theme.syntax.keyword_type <> "def" <> theme.reset + + unless String.contains?(elixir, elixir_keyword) and String.contains?(ruby, ruby_keyword) do raise "Markdown syntax-highlighting runtime is unavailable" end diff --git a/app/mix.exs b/app/mix.exs index 8ba2eb2..491fbfb 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -62,6 +62,7 @@ defmodule LinearCli.MixProject do [ {:marcli, "~> 0.3"}, {:makeup_elixir, "~> 1.0"}, + {:makeup_syntect, "~> 0.1"}, {:owl, "~> 0.13"}, {:optimus, "~> 0.6"}, {:req, "~> 0.7"}, diff --git a/app/mix.lock b/app/mix.lock index 812af6c..3130ce0 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -2,6 +2,7 @@ "ash": {:hex, :ash, "3.31.3", "b36672bff745eadd52265d8b0a303f98ba26a31173f4116ebd2df7be0cbafeff", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c105da2080475d114908c32e70970e69c857a9068a577a4e99dbf2801baa11e"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "crux": {:hex, :crux, "0.1.4", "1fa21f5ca886d3498f83871a3ef19379b80abf8cfcf2ed32007e80279e714f1e", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ff7d880cf732d82360aa81e439b8d4831cd30768061c9233f90c97e10162b9c0"}, @@ -24,6 +25,7 @@ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_syntect": {:hex, :makeup_syntect, "0.1.4", "e1230c9e0513c667b226b21c83eb182e1ab581f65af9441edab1f9ac626acba6", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}, {:rustler, "~> 0.37.1", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8.2", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5b624a434d9665786b9a352a5f3502b6c98e1996ede9936b20035ec140daef70"}, "marcli": {:hex, :marcli, "0.3.1", "183d2c674e416cdd90a5b395d60d573ad5d4381ec18239de2b2cfa249d926d7c", [:mix], [{:color, "~> 0.3", [hex: :color, repo: "hexpm", optional: true]}, {:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex, "~> 0.11", [hex: :mdex, repo: "hexpm", optional: false]}], "hexpm", "731383f9b9bdfd13affe2d43a3cef441440c4a9182cb59724478e51b85e2c7d4"}, "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, "mdex_native": {:hex, :mdex_native, "0.2.7", "46203ee9c4fe2c94feef26e4d976235ad022b4770d3d307fa6b445d293b2571f", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "05efa68775644ef6be6109f76c1f5f8778ff7e7ed9fe4a03541758158172bc77"}, @@ -45,7 +47,7 @@ "reactor": {:hex, :reactor, "1.0.6", "546a87255693bcee99451d022cc86927161cdb527f5216d4dcac2b31e08eb122", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "e3f8fd8e870c2b011316ca2ac422bb4bc710cac9f02c578f8526f7a6348d932b"}, "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, diff --git a/app/test/linear_cli/cli/display_test.exs b/app/test/linear_cli/cli/display_test.exs index 5c65d94..616673d 100644 --- a/app/test/linear_cli/cli/display_test.exs +++ b/app/test/linear_cli/cli/display_test.exs @@ -29,4 +29,30 @@ defmodule LinearCli.CLI.DisplayTest do assert output =~ theme.syntax.number <> "42" <> theme.reset refute output =~ theme.code_text <> "defmodule" end + + test "full issue output syntax-highlights fenced Ruby code through Syntect" do + issue = %Issue{ + id: "issue-1", + identifier: "EXT-1", + title: "Highlight Ruby markdown", + description: """ + ```ruby + class Greeter + def hello(name) + puts "Hello!" + end + end + ``` + """, + comments: [] + } + + output = capture_io(fn -> Display.show(issue, %{full: true}) end) + theme = Marcli.Theme.default() + + assert output =~ theme.syntax.keyword_type <> "class" <> theme.reset + assert output =~ theme.syntax.name_class <> "Greeter" <> theme.reset + assert output =~ theme.syntax.name_function <> "hello" <> theme.reset + refute output =~ theme.code_text <> "class Greeter" + end end diff --git a/ci/bundle_mdex_musl_libgcc.sh b/ci/bundle_mdex_musl_libgcc.sh deleted file mode 100755 index cd96efa..0000000 --- a/ci/bundle_mdex_musl_libgcc.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/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]}" diff --git a/ci/prepare_musl_nifs.sh b/ci/prepare_musl_nifs.sh new file mode 100755 index 0000000..96f65d4 --- /dev/null +++ b/ci/prepare_musl_nifs.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "$script_dir/.." && pwd) +build_lib_dir=${1:-"$repo_root/app/_build/prod/lib"} + +mdex_native_dir="$build_lib_dir/mdex_native/priv/native" +syntect_priv_dir="$build_lib_dir/makeup_syntect/priv" + +# Mix links a dependency's priv directory back into deps/ by default. Replace +# that production-build symlink with a private copy before swapping NIFs, or a +# local release build would also replace the glibc NIF used by dev/test. +if [ -L "$syntect_priv_dir" ]; then + syntect_priv_source=$(readlink -f -- "$syntect_priv_dir") + isolated_priv=$(mktemp -d "$build_lib_dir/makeup_syntect/.priv.XXXXXX") + + if ! cp -a "$syntect_priv_source/." "$isolated_priv/"; then + rm -rf -- "$isolated_priv" + exit 1 + fi + + unlink "$syntect_priv_dir" + mv "$isolated_priv" "$syntect_priv_dir" +fi + +syntect_native_dir="$syntect_priv_dir/native" + +for native_dir in "$mdex_native_dir" "$syntect_native_dir"; do + if [ ! -d "$native_dir" ]; then + printf 'native directory does not exist: %s\n' "$native_dir" >&2 + exit 1 + fi +done + +mdex_native_dir=$(cd -- "$mdex_native_dir" && pwd -P) +syntect_native_dir=$(cd -- "$syntect_native_dir" && pwd -P) + +shopt -s nullglob +mdex_nifs=("$mdex_native_dir"/libmdex_native_nif-*-unknown-linux-musl.so) +syntect_host_nifs=("$syntect_native_dir"/libmakeup_syntect-*-unknown-linux-gnu.so) +shopt -u nullglob + +if [ "${#mdex_nifs[@]}" -ne 1 ]; then + printf 'expected exactly one mdex_native musl NIF in %s; found %d\n' \ + "$mdex_native_dir" "${#mdex_nifs[@]}" >&2 + exit 1 +fi + +if [ "${#syntect_host_nifs[@]}" -ne 1 ]; then + printf 'expected exactly one makeup_syntect host NIF in %s; found %d\n' \ + "$syntect_native_dir" "${#syntect_host_nifs[@]}" >&2 + exit 1 +fi + +# makeup_syntect invokes its NIF while compiling its generated syntax-list +# documentation. Compiling it with TARGET_ABI=musl on an Ubuntu runner therefore +# fails before Mix can write a complete application. Compile it normally first, +# then replace the host NIF (at the path embedded in its BEAM module) with the +# checksummed musl release artifact. +syntect_host_nif=${syntect_host_nifs[0]} +syntect_host_name=$(basename -- "$syntect_host_nif") +syntect_musl_name=${syntect_host_name/unknown-linux-gnu/unknown-linux-musl} +syntect_archive="$syntect_musl_name.tar.gz" + +if [[ ! "$syntect_musl_name" =~ ^libmakeup_syntect-v([^-]+)- ]]; then + printf 'could not determine makeup_syntect version from %s\n' "$syntect_host_name" >&2 + exit 1 +fi + +syntect_version=${BASH_REMATCH[1]} +checksum_file="$repo_root/app/deps/makeup_syntect/checksum-Elixir.MakeupSyntect.exs" + +if [ ! -f "$checksum_file" ]; then + printf 'makeup_syntect checksum file does not exist: %s\n' "$checksum_file" >&2 + exit 1 +fi + +expected_checksum=$( + elixir -e ' + [path, artifact] = System.argv() + {checksums, _bindings} = Code.eval_file(path) + IO.write(Map.fetch!(checksums, artifact)) + ' -- "$checksum_file" "$syntect_archive" +) +expected_checksum=${expected_checksum#sha256:} + +temp_dir=$(mktemp -d) +cleanup() { + rm -rf -- "$temp_dir" +} +trap cleanup EXIT + +syntect_url="https://github.com/elixir-makeup/makeup_syntect/releases/download/v${syntect_version}/${syntect_archive}" +downloaded_archive="$temp_dir/$syntect_archive" + +curl --fail --location --silent --show-error --retry 3 \ + --output "$downloaded_archive" "$syntect_url" + +actual_checksum=$(sha256sum "$downloaded_archive" | cut -d ' ' -f 1) + +if [ "$actual_checksum" != "$expected_checksum" ]; then + printf 'checksum mismatch for %s\nexpected: %s\nactual: %s\n' \ + "$syntect_archive" "$expected_checksum" "$actual_checksum" >&2 + exit 1 +fi + +tar -xzf "$downloaded_archive" -C "$temp_dir" +downloaded_nif="$temp_dir/$syntect_musl_name" + +if [ ! -f "$downloaded_nif" ]; then + printf 'makeup_syntect archive did not contain %s\n' "$syntect_musl_name" >&2 + exit 1 +fi + +install -m 0755 "$downloaded_nif" "$syntect_host_nif" + +# 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 Ubuntu's glibc libgcc_s may be found first. Give each +# NIF a uniquely named Alpine libgcc runtime beside it and patch DT_NEEDED and +# RUNPATH so the loader selects that copy deterministically. +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 prepare the musl NIFs\n' >&2 + exit 1 +fi + +container_args=(run --rm) + +if [ "$container_runtime" = podman ]; then + container_args+=(--security-opt label=disable) +fi + +container_args+=( + -v "$mdex_native_dir:/mdex_native" + -v "$syntect_native_dir:/makeup_syntect" +) + +mdex_nif_name=$(basename -- "${mdex_nifs[0]}") + +# 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 + + while [ "$#" -gt 0 ]; do + nif="$1" + libgcc_name="$2" + shift 2 + + bundled_libgcc="${nif%/*}/$libgcc_name" + install -m 0755 /usr/lib/libgcc_s.so.1 "$bundled_libgcc" + + # libc.so is the musl dependency name; glibc NIFs require libc.so.6. + # Check this before patching so a host artifact cannot slip through. + patchelf --print-needed "$nif" | grep -Fxq libc.so + + # 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 "$libgcc_name" "$nif" + elif ! patchelf --print-needed "$nif" | grep -Fxq "$libgcc_name"; then + printf "musl NIF has no expected libgcc dependency: %s\n" "$nif" >&2 + exit 1 + fi + + patchelf --print-needed "$nif" | grep -Fxq "$libgcc_name" + test "$(patchelf --print-rpath "$nif")" = "\$ORIGIN" + done +' sh \ + "/mdex_native/$mdex_nif_name" libmdex_musl_libgcc_s.so.1 \ + "/makeup_syntect/$syntect_host_name" libmakeup_syntect_musl_libgcc_s.so.1 + +printf 'prepared musl NIF: %s\n' "${mdex_nifs[0]}" +printf 'prepared musl NIF: %s\n' "$syntect_host_nif"