Skip to content

Commit 6ee403f

Browse files
committed
Add precompiled NIF distribution via GitHub Releases
Build precompiled NIF binaries for x86_64-linux, aarch64-linux, aarch64-macos, and x86_64-macos on version tag push. The Elixir library auto-downloads the correct binary during mix compile, falling back to Zigler source compilation when unavailable. Set TAILWIND_COMPILER_PATH=true to force source compilation. Zigler is now an optional dependency for precompiled consumers. Version 0.0.1
1 parent 62f0972 commit 6ee403f

6 files changed

Lines changed: 406 additions & 95 deletions

File tree

.github/workflows/release.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build_nif:
13+
name: Build NIF (${{ matrix.target }})
14+
runs-on: ${{ matrix.runner }}
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
include:
19+
- target: x86_64-linux
20+
runner: ubuntu-22.04
21+
- target: aarch64-linux
22+
runner: ubuntu-22.04
23+
use_qemu: true
24+
- target: aarch64-macos
25+
runner: macos-14
26+
- target: x86_64-macos
27+
runner: macos-13
28+
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
# --- Native builds (x86_64-linux, macos) ---
33+
- name: Set up Erlang/Elixir
34+
if: ${{ !matrix.use_qemu }}
35+
uses: erlef/setup-beam@v1
36+
with:
37+
otp-version: "27"
38+
elixir-version: "1.17"
39+
40+
- name: Build NIF (native)
41+
if: ${{ !matrix.use_qemu }}
42+
env:
43+
TAILWIND_COMPILER_PATH: "true"
44+
run: |
45+
mix local.hex --force
46+
mix local.rebar --force
47+
mix deps.get
48+
mix compile
49+
50+
# --- QEMU cross-build (aarch64-linux) ---
51+
- name: Set up QEMU
52+
if: ${{ matrix.use_qemu }}
53+
uses: docker/setup-qemu-action@v3
54+
with:
55+
platforms: arm64
56+
57+
- name: Build NIF (aarch64-linux via QEMU)
58+
if: ${{ matrix.use_qemu }}
59+
run: |
60+
docker run --rm --platform linux/arm64 \
61+
-v "${{ github.workspace }}:/workspace" \
62+
-w /workspace \
63+
-e TAILWIND_COMPILER_PATH=true \
64+
-e MIX_ENV=prod \
65+
hexpm/elixir:1.17.3-erlang-27.2-ubuntu-noble-20241113 \
66+
bash -c "
67+
mix local.hex --force &&
68+
mix local.rebar --force &&
69+
mix deps.get &&
70+
mix compile
71+
"
72+
73+
# --- Package ---
74+
- name: Package NIF
75+
run: |
76+
NIF_FILE=$(find _build -name "Elixir.TailwindCompiler.NIF.so" -type f | head -1)
77+
if [ -z "$NIF_FILE" ]; then
78+
echo "ERROR: Could not find compiled NIF .so file"
79+
find _build -name "*NIF*" -type f
80+
exit 1
81+
fi
82+
echo "Found NIF at: $NIF_FILE"
83+
mkdir -p release
84+
cp "$NIF_FILE" release/Elixir.TailwindCompiler.NIF.so
85+
cd release
86+
tar czf "tailwind_compiler-nif-${{ matrix.target }}.tar.gz" Elixir.TailwindCompiler.NIF.so
87+
shasum -a 256 "tailwind_compiler-nif-${{ matrix.target }}.tar.gz" | tee "tailwind_compiler-nif-${{ matrix.target }}.tar.gz.sha256"
88+
89+
- name: Upload artifact
90+
uses: actions/upload-artifact@v4
91+
with:
92+
name: nif-${{ matrix.target }}
93+
path: release/tailwind_compiler-nif-*
94+
95+
release:
96+
name: Create Release
97+
needs: build_nif
98+
runs-on: ubuntu-latest
99+
steps:
100+
- name: Download all artifacts
101+
uses: actions/download-artifact@v4
102+
with:
103+
path: artifacts
104+
merge-multiple: true
105+
106+
- name: Create GitHub Release
107+
uses: softprops/action-gh-release@v2
108+
with:
109+
files: artifacts/*
110+
generate_release_notes: true

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,30 @@ Benchmarked against the official Tailwind CLI v4.2.2 — 10 rounds, each with 10
1616

1717
Zero output differences — every candidate produces byte-identical CSS to the official Tailwind CLI.
1818

19-
## Elixir Usage
19+
## Installation
2020

2121
Add to your `mix.exs`:
2222

2323
```elixir
2424
def deps do
25-
[{:tailwind_compiler, github: "BeaconCMS/tailwind_compiler"}]
25+
[{:tailwind_compiler, "~> 0.0.1"}]
2626
end
2727
```
2828

29-
Then:
29+
Precompiled NIF binaries are available for `x86_64-linux`, `aarch64-linux`, `aarch64-macos`, and `x86_64-macos`. The correct binary is downloaded automatically during `mix compile`.
30+
31+
To force compilation from source (requires [Zig 0.15.2+](https://ziglang.org/download/) and the `zigler` dependency):
32+
33+
```elixir
34+
# Add zigler to your deps
35+
{:zigler, "~> 0.15.1", runtime: false}
36+
```
37+
38+
```bash
39+
TAILWIND_COMPILER_PATH=true mix compile
40+
```
41+
42+
## Elixir Usage
3043

3144
```elixir
3245
TailwindCompiler.compile(["flex", "p-4", "hover:bg-blue-500/50", "sm:text-lg"])

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
.name = .tailwind_compiler,
1010
// This is a [Semantic Version](https://semver.org/).
1111
// In a future version of Zig it will be used for package deduplication.
12-
.version = "0.0.0",
12+
.version = "0.0.1",
1313
// Together with name, this represents a globally unique package
1414
// identifier. This field is generated by the Zig toolchain when the
1515
// package is first created, and then *never changes*. This allows

lib/tailwind_compiler/native.ex

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
defmodule TailwindCompiler.Native do
2+
@moduledoc false
3+
4+
@version Mix.Project.config()[:version]
5+
@github_repo "beaconcms/tailwind_compiler"
6+
7+
@doc """
8+
Determine whether to use a precompiled NIF.
9+
10+
Returns `true` if a precompiled NIF is available (cached or downloaded).
11+
Returns `false` if `TAILWIND_COMPILER_PATH` is set, or if no precompiled
12+
NIF could be found, in which case Zigler compilation is used as fallback.
13+
"""
14+
def use_precompiled? do
15+
not force_build?() and ensure_precompiled()
16+
end
17+
18+
@doc """
19+
Whether to force building from source via Zigler.
20+
"""
21+
def force_build? do
22+
System.get_env("TAILWIND_COMPILER_PATH") != nil
23+
end
24+
25+
@doc """
26+
Returns the NIF path (without extension) for `:erlang.load_nif/2`.
27+
Uses `:code.priv_dir/1` so it works at runtime (including in releases).
28+
"""
29+
def nif_path do
30+
priv =
31+
case :code.priv_dir(:tailwind_compiler) do
32+
{:error, _} -> build_priv_dir()
33+
path -> List.to_string(path)
34+
end
35+
36+
Path.join([priv, "native", "Elixir.TailwindCompiler.NIF"])
37+
end
38+
39+
@doc """
40+
The target triple for the current platform (e.g. "aarch64-macos").
41+
"""
42+
def target do
43+
"#{arch()}-#{os()}"
44+
end
45+
46+
# --- Private ---
47+
48+
defp ensure_precompiled do
49+
so_path = nif_so_path()
50+
51+
if File.exists?(so_path) do
52+
true
53+
else
54+
download()
55+
end
56+
end
57+
58+
defp nif_so_path do
59+
Path.join(native_dir(), "Elixir.TailwindCompiler.NIF.so")
60+
end
61+
62+
defp native_dir do
63+
Path.join(build_priv_dir(), "native")
64+
end
65+
66+
defp build_priv_dir do
67+
Path.join([Mix.Project.build_path(), "lib", "tailwind_compiler", "priv"])
68+
end
69+
70+
defp download do
71+
target_triple = target()
72+
73+
if String.contains?(target_triple, "unknown") do
74+
log("Unsupported platform: #{target_triple}, skipping precompiled NIF download")
75+
false
76+
else
77+
url = download_url(target_triple)
78+
log("Downloading precompiled NIF for #{target_triple}...")
79+
80+
dest = native_dir()
81+
File.mkdir_p!(dest)
82+
83+
case download_and_extract(url, dest) do
84+
:ok ->
85+
if File.exists?(nif_so_path()) do
86+
log("Successfully installed precompiled NIF")
87+
true
88+
else
89+
log("Archive did not contain expected NIF file")
90+
false
91+
end
92+
93+
{:error, reason} ->
94+
log("Could not download precompiled NIF: #{inspect(reason)}")
95+
log("Falling back to compiling from source...")
96+
false
97+
end
98+
end
99+
end
100+
101+
defp download_url(target_triple) do
102+
"https://github.com/#{@github_repo}/releases/download/v#{@version}/tailwind_compiler-nif-#{target_triple}.tar.gz"
103+
end
104+
105+
defp download_and_extract(url, dest, redirects_left \\ 5) do
106+
if redirects_left <= 0 do
107+
{:error, :too_many_redirects}
108+
else
109+
:inets.start()
110+
:ssl.start()
111+
112+
http_opts = [ssl: ssl_opts()]
113+
114+
case :httpc.request(:get, {String.to_charlist(url), []}, http_opts, body_format: :binary) do
115+
{:ok, {{_, 200, _}, _headers, body}} ->
116+
:erl_tar.extract({:binary, body}, [:compressed, {:cwd, String.to_charlist(dest)}])
117+
118+
{:ok, {{_, status, _}, headers, _body}} when status in [301, 302, 303, 307, 308] ->
119+
case List.keyfind(headers, ~c"location", 0) do
120+
{_, location} ->
121+
download_and_extract(List.to_string(location), dest, redirects_left - 1)
122+
123+
nil ->
124+
{:error, "HTTP #{status} without Location header"}
125+
end
126+
127+
{:ok, {{_, status, _}, _headers, _body}} ->
128+
{:error, "HTTP #{status}"}
129+
130+
{:error, reason} ->
131+
{:error, reason}
132+
end
133+
end
134+
end
135+
136+
defp ssl_opts do
137+
[
138+
verify: :verify_peer,
139+
cacerts: :public_key.cacerts_get(),
140+
depth: 3,
141+
customize_hostname_check: [
142+
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
143+
]
144+
]
145+
end
146+
147+
defp arch do
148+
arch_str = :erlang.system_info(:system_architecture) |> List.to_string()
149+
150+
cond do
151+
String.starts_with?(arch_str, "aarch64") -> "aarch64"
152+
String.starts_with?(arch_str, "arm") -> "aarch64"
153+
String.contains?(arch_str, "x86_64") -> "x86_64"
154+
String.contains?(arch_str, "amd64") -> "x86_64"
155+
true -> "unknown"
156+
end
157+
end
158+
159+
defp os do
160+
case :os.type() do
161+
{:unix, :linux} -> "linux"
162+
{:unix, :darwin} -> "macos"
163+
_ -> "unknown"
164+
end
165+
end
166+
167+
defp log(message) do
168+
if function_exported?(Mix, :shell, 0) do
169+
Mix.shell().info(message)
170+
end
171+
end
172+
end

0 commit comments

Comments
 (0)