Py2Native Compile custom Python into native machine code to protect proprietary code

← All posts

2026-10-08

Automating Python-to-Native Builds in CI With Py2Native

Automating Python-to-Native Builds in CI With Py2Native
python compiler cython code protection open source

Automating Python-to-Native Builds in CI With Py2Native

Every release, someone on the team does the same dance: open a laptop with the right toolchain installed, remember the flag combination, run the build, copy the .pyd or .so out of a temp directory, rename it, upload it somewhere, and do it all again on the other two platforms. Nothing about that is hard, but it’s tedious enough that it only happens when someone remembers to do it — and that’s exactly the kind of task that should be a CI job instead of a person.

This article is about turning that dance into one job in your pipeline. If your goal is to automate a Python build to a native binary in CI, Py2Native gives you the short version: you write plain Python, run one command, and CI produces a compiled, protected artifact. No Cython syntax, no .pyx or .pxd authoring, no manual cythonize step, no C extensions to write.

There is a hard way, of course — hand-written C extensions and hand-maintained Cython pipelines are how this used to get done, and we won’t re-litigate it here. If you want the comparison, read How to Protect Python Source Code From Being Read (Without Writing Cython) and come back. The rest of this post assumes you want the automated path.

Why Manual Native Builds Don’t Belong in a Release Process

A manual native build fails in three predictable ways:

  • Flag drift. Someone builds with --library, someone else forgets and ships an executable. The output looks fine until it doesn’t import.
  • Platform amnesia. The Windows build is reproducible only on the one machine that happened to have MSVC configured correctly.
  • Bus factor. The build exists as tribal knowledge instead of as a file in the repo.

The goal is a repeatable CI job that turns plain Python into a compiled binary without a developer babysitting it. Concretely: a job that installs uv, runs uv run py2native build, and uploads a wheel. That’s it. Every push produces a protected artifact that CI can publish on its own.

Py2Native is the zero-config compiler in that story. You write plain Python, invoke uv run py2native build, and get native output. The tool generates its own bootstrap.pyx from templates, invokes Cython, compiles the resulting C, and links the shared library for you — that machinery is real, but it’s not yours to maintain.

One-Time Setup: Making a Repo CI-Ready for Py2Native

Two rules for the setup: pin everything, and invoke the compiler through uv — never pip.

Toolchain requirements. The build needs CPython 3.11–3.15 (including free-threaded 3.14t and 3.15t), a platform C compiler and linker (MSVC on Windows, GCC on Linux, Clang on macOS), uv >= 0.11.8, and internet access so the build can download Python distributions and libraries. Targets are Windows 8+, manylinux2014 or musl Linux, and macOS, on x86-64 or ARM64.

Dependencies. The open-source core (py2native, MIT licensed) depends on cython >= 3.2.4, setuptools >= 82.0.1, and uv >= 0.11.8; on Linux it also uses auditwheel >= 6.6.0 for wheel repair. Add the core to your project, and add the Pro package (py2nativepro) if you need license enforcement — that’s covered later.

# one-time, in the repo
uv add py2native
# only if you plan to enforce signed licenses:
uv add py2nativepro

Minimal project layout. A src/ tree with your modules plus a py2native/ directory that holds the compiler internals — cli.py (the py2native build entry point), build.py (orchestration), compiler.py (Cython invocation, C compilation, linking), embed.py, and whl.py.

myproject/
├── pyproject.toml
├── src/
│   ├── main.py
│   ├── billing.py
│   └── reports.py
└── py2native/
    ├── cli.py
    ├── build.py
    ├── compiler.py
    ├── embed.py
    └── whl.py

Constraints to know before CI trips over them. Document these in your README so a pipeline failure is a one-line diagnosis:

  • No modules with identical names across source files.
  • __init__.py is skipped during compilation — it’s a namespace marker, not compilable code.
  • In library mode, __main__.py is excluded from your sources because the wheel builder generates it.

If you ship signed licenses, generate the keypair once. This is a Pro step and it happens outside the regular build:

uv run py2native keygen private.pem public.pem
uv run py2native sign --private private.pem payload.json license.dat

Store private.pem in your CI secret store. The public key is the only half that ever ships.

The Automated Pipeline: One Build Command in Your CI Workflow

Here’s the command your pipeline runs:

uv run py2native build main.py *.py \
  --library \
  --wheel dist/ \
  --base src

What each piece does:

  • main.py *.py — the entry module plus a glob of sources; build.py expands the glob under --base.
  • --base src — the root the glob patterns are resolved against.
  • --library — produce a shared library (.pyd / .so / .dylib) instead of an executable.
  • --wheel dist/ — emit a PEP 427 wheel into dist/ containing the compiled library.

Under the hood, build.py walks a fixed pipeline: expand the glob patterns, dispatch plugins (pluginManager.extendBuild() runs Pro license checks), generate bootstrap.pyx from templates.py and invoke Cython with --embed --shared --embed-modules to turn .pyx into C, compile those .c files with platform flags via getCompiler(), link with link_shared_object() for library mode or link_executable() for exe mode, then run the optional wheel and embed steps. You don’t call any of those stages yourself — the single command does.

Here’s a GitHub Actions job that installs uv, builds, and publishes the wheel:

name: build-native

on:
  push:
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v5
        with:
          version: ">=0.11.8"

      - name: Install toolchain
        run: sudo apt-get install -y gcc

      - name: Build native wheel
        run: |
          uv run py2native build main.py '*.py' \
            --library \
            --wheel dist/ \
            --base src

      - uses: actions/upload-artifact@v4
        with:
          name: native-wheel
          path: dist/*.whl

What library mode actually outputs. The wheel contains a single compiled shared library holding all your modules, plus two generated files that are both required:

  • __init__.py — a meta-path finder that redirects import mypkg.submodule into the shared library, bridging Python’s import system to native code.
  • __main__.py — the entry point that makes python -m mypkg work.

If you’d rather ship a runnable environment than a wheel, add --embed <dir>. That produces a uv-managed deployment directory — useful for desktop apps where you want a self-contained install. And where a console window is undesirable, pass --no-console. You never hand-write platform flags: the winplatform, linuxplatform, and macplatform plugins supply the right compile and link settings, plus things like auditwheel repair on Linux, for the machine you’re building on.

Adding License Enforcement to the Automated Build

If you sell software, a compiled binary solves code theft but not license control. That’s what the Pro plugin (py2nativepro) adds: ES256 signature verification and your public key are baked into the executable at build time. Only the public key ships — the private key never leaves your signing environment. The elliptic curve verification is handled with compiled code inside the software, using no third-party libraries.

To use the verification entry point from your own compiled code, declare it with a .pxd file:

# _p2n_bootstrap.pxd
cdef _runtime_verify_es256_jwt(token, expected_iss=*, expected_aud=*)

and import it in the module that performs the check:

from _p2n_bootstrap cimport _runtime_verify_es256_jwt

license = _runtime_verify_es256_jwt(
    licenseString,
    expected_iss="RSJ Software GmbH",
    expected_aud="TimestampGIT",
)

Because the issuer and audience are pinned at the call site, a JWT signed for a different product won’t pass even if the signature is valid.

Wire signing into CI as a separate, protected job. Generate the keypair once, sign the payload in the job that has access to private.pem, and hand the result to the build:

      - name: Sign license payload
        env:
          PRIVATE_KEY: ${{ secrets.P2N_PRIVATE_KEY }}
        run: |
          printf '%s' "$PRIVATE_KEY" > private.pem
          uv run py2native sign --private private.pem payload.json license.dat

      - name: Build with license enforcement
        run: |
          uv run py2native build main.py '*.py' \
            --library \
            --wheel dist/ \
            --base src \
            --license license.dat \
            --public public.pem

With --license and --public supplied, the build requires a valid Pro license. When something looks wrong, inspect the claims rather than guessing:

uv run py2native show license.dat
uv run py2native show --public public.pem license.dat   # also verifies the signature

Monitoring, Failure Handling, and Artifact Hygiene

A CI job that fails silently is worse than no job. A few habits make native builds debuggable.

Fail fast on prerequisites. Check the compiler, the uv version, and network reachability before the build starts. The build downloads Python distributions and libraries, so a sandboxed runner without egress will fail — better to fail with a clear message than midway through linking.

uv --version
cc --version   # gcc on Linux; cl /? under MSVC on Windows

Capture compiler and linker output as artifacts. Compilation and link failures are platform-specific, and you don’t want to reproduce a Windows link error on a laptop. Redirect the build log to a file and upload it with the wheel.

      - name: Build native wheel
        run: |
          mkdir -p logs
          uv run py2native build main.py '*.py' \
            --library --wheel dist/ --base src \
            2>&1 | tee logs/build.log

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: build-logs
          path: logs/

Verify the wheel before publishing. Before the upload step, assert that the compiled .pyd/.so is inside the archive and that library mode included both generated files:

uv run python - <<'PY'
import zipfile, glob, sys
whl = glob.glob("dist/*.whl")[0]
names = zipfile.ZipFile(whl).namelist()
print("\n".join(names))
assert any(n.endswith((".pyd", ".so", ".dylib")) for n in names), "no compiled library"
assert any(n.endswith("__init__.py") for n in names), "missing __init__.py"
assert any(n.endswith("__main__.py") for n in names), "missing __main__.py"
print("wheel OK:", whl)
PY

Treat Linux wheel repair as a release blocker. The linuxplatform plugin runs auditwheel repair to keep wheels manylinux2014/musl compliant. If that step fails, stop — don’t publish a wheel that installs on the build machine and nowhere else.

Keep secrets out of everything you ship. private.pem and license.dat must never end up in the wheel, in build logs, or in artifact uploads. Only the public key belongs in the compiled binary.

Best Practices for Reliable Automated Native Builds

Build per platform in a matrix. Expand the job across Windows (8+), manylinux2014/musl Linux, and macOS on x86-64 or ARM64. Each runner builds natively with its own platform plugin; don’t try to cross-compile everything from one job.

    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}

Pin the build tools. cython >= 3.2.4, setuptools >= 82.0.1, and auditwheel >= 6.6.0 on Linux should be pinned so two runs a month apart produce the same artifact.

Leave third-party libraries alone. Py2Native leaves dependencies as Python source and they work as-is. Don’t try to compile them — that’s the part that historically required extensive build configuration, and you’re skipping it.

Know the boundaries. Native compilation protects your code. Monkey-patching of third-party libraries is still possible because they remain Python source, and LGPL libraries remain replaceable by the end user for compliance reasons. If those boundaries matter for your product, Protect Python Code for Commercial Software Vendors covers the tradeoffs in more detail.

Version and trace artifacts. Tie each wheel to the commit SHA and the license payload that produced it, so a support ticket resolves to an exact build.

FAQ

Can I build a native Python binary in CI without writing any Cython?

Yes. With Py2Native you write plain Python and run uv run py2native build main.py *.py --library --wheel dist/. The tool generates its own bootstrap.pyx from templates, invokes Cython, compiles the C, and links the result — no .pyx or .pxd authoring and no manual cythonize step on your side.

How do I enforce signed licenses in an automated build?

Use the Pro plugin. Generate a keypair once with uv run py2native keygen private.pem public.pem, sign a payload with uv run py2native sign --private private.pem payload.json license.dat, and pass --license license.dat --public public.pem to the build. In your code, declare and call the verification entry point: from _p2n_bootstrap cimport _runtime_verify_es256_jwt, then license = _runtime_verify_es256_jwt(licenseString, expected_iss="RSJ Software GmbH", expected_aud="TimestampGIT"). The public key and the ES256 verification logic are baked into the executable.

What does the CI job need installed to run the build?

CPython 3.11–3.15 (including free-threaded 3.14t and 3.15t), a platform C compiler and linker (MSVC on Windows, GCC on Linux, Clang on macOS), uv >= 0.11.8, and internet access so the build can download Python distributions and libraries. The open-source core also depends on cython >= 3.2.4 and setuptools >= 82.0.1, with auditwheel >= 6.6.0 on Linux.

What artifacts should the pipeline publish?

In library mode, publish the PEP 427 wheel containing the single compiled shared library (.pyd/.so/.dylib) plus the generated __init__.py meta-path finder and __main__.py entry point. If you prefer a runnable deployment directory instead of a wheel, use --embed <dir> to produce a uv-managed environment.

Conclusion

The manual native build isn’t hard, it’s just not something a human should own. Move it into CI as one uv run py2native build step, pin the toolchain, verify the wheel before publishing, and let the platform plugins worry about flags. When you need license enforcement, the Pro plugin bakes the public key and ES256 verification into the same artifact.

Start from py2native.dev — the core compiler is MIT licensed and takes about as long to wire up as the YAML above. If you’re shipping commercial software with signed licenses, look at the Pro tier as well.

Related posts

EU label: AI-generated content