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

← All posts

2026-09-19

Automate Python Code Protection in Your CI/CD Pipeline

Automate Python Code Protection in Your CI/CD Pipeline
python compiler cython code protection open source

Automate Python Code Protection in Your CI/CD Pipeline

Manually compiling Python code for every release sounds like a small task until it becomes the step that blocks a release. A developer runs the build on their laptop, remembers or forgets the right flags, uploads an artifact through a web UI, and then everyone hopes it matches the commit that shipped. That is time-consuming, error-prone, and impossible to audit.

Py2Native removes that manual step. It compiles your own Python code into native machine code, so your CI/CD system can build a protected binary or wheel on every push. This article walks through a one-time setup, a real pipeline, failure handling, and the practices that keep the process predictable.

One-Time Setup: Configure Py2Native for Your Project

The goal is to make compilation a repeatable command. Start with a uv-managed project:

uv init --python 3.12 protected-app
cd protected-app
uv add py2native

The open-source py2native package gives you the full compiler, uv embedding, and cross-platform builds. There is no manual Cython step, no .pyx files to maintain for a normal Community build, and no need to write C extensions.

Next, turn the compilation command into a Makefile target or a small shell script:

.PHONY: compile
compile:
	mkdir -p dist
	uv run py2native build \
		--base . \
		--wheel dist/ \
		src/app/main.py \
		src/app/core/*.py \
		src/app/services/*.py

This compiles your custom Python sources into a PEP 427 wheel under dist/. The --base . flag keeps glob expansion anchored to the project root, and the source patterns are passed to Py2Native instead of relying on shell behavior.

If you ship standalone deployment directories instead of wheels, use --embed:

uv run py2native build --base . --embed release/app \
  src/app/main.py src/app/core/*.py src/app/services/*.py

Pro license verification during setup

For Py2Native Pro, the build can also verify that only licensed users can run the protected code. The Pro plugin adds JWT license verification, string compression, and builds a public key directly into the executable.

Generate an EC P-256 keypair:

uv run py2native keygen private.pem public.pem

Sign a JWT payload with the private key to create a license file:

uv run py2native sign --private private.pem license_payload.json license.dat

The private key belongs in a CI/CD secret or secret manager. The public key and license file can be used during compilation. Your project keeps a small Pro declaration file:

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

Then a compiled call site checks the license:

# license_check.pyx
from _p2n_bootstrap cimport _runtime_verify_es256_jwt

def enforce_license(licenseString: str) -> bool:
    license = _runtime_verify_es256_jwt(
        licenseString,
        expected_iss="RSJ Software GmbH",
        expected_aud="TimestampGIT",
    )
    if not license:
        raise RuntimeError("Invalid or expired license")
    return True

The Pro plugin bakes the signature verification logic and the public key into the native executable. Only the public key is stored locally, and the elliptic-curve verification is compiled code, not a third-party library call. This gives you a license check that is part of the binary rather than a Python file someone can edit.

The Automated Pipeline: Integrate Py2Native into CI/CD

Py2Native is a command-line tool, so it works anywhere your CI system can run uv. The pipeline needs three things:

  • CPython 3.11–3.15, including free-threaded 3.14t and 3.15t
  • A platform C compiler: MSVC on Windows, GCC on Linux, Clang on macOS
  • Internet access for Python distributions and dependency downloads

Here is a GitHub Actions workflow that compiles the project, smoke-tests the wheel, and uploads the artifact:

name: Build protected Python

on:
  push:
    branches: [main]

jobs:
  compile:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v6
        with:
          python-version: "3.12"

      - name: Install C compiler
        run: |
          sudo apt-get update
          sudo apt-get install -y build-essential

      - name: Restore locked dependencies
        run: uv sync --locked

      - name: Write license and public key from secrets
        env:
          LICENSE_B64: ${{ secrets.PY2NATIVE_LICENSE_B64 }}
          PUBLIC_KEY_B64: ${{ secrets.PY2NATIVE_PUBLIC_B64 }}
        run: |
          echo "$LICENSE_B64" | base64 -d > license.dat
          echo "$PUBLIC_KEY_B64" | base64 -d > public.pem

      - name: Compile custom Python to native code
        run: |
          mkdir -p dist
          uv run py2native build \
            --base . \
            --wheel dist/ \
            --license license.dat \
            --public public.pem \
            src/app/main.py \
            src/app/core/*.py \
            src/app/services/*.py

      - name: Smoke test compiled wheel
        run: |
          uv venv .smoke
          . .smoke/bin/activate
          uv pip install dist/*.whl
          python -c "import app; print('native import ok')"
          deactivate

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

For GitLab CI, the same command runs in a shell stage:

stages: [compile, smoke, release]

compile:
  stage: compile
  image: python:3.12
  before_script:
    - apt-get update && apt-get install -y build-essential
    - curl -LsSf https://astral.sh/uv/install.sh | sh
    - . "$HOME/.cargo/env"
    - uv sync --locked
  script:
    - mkdir -p dist
    - uv run py2native build
        --base .
        --wheel dist/
        --license license.dat
        --public public.pem
        src/app/main.py
        src/app/core/*.py
        src/app/services/*.py
  artifacts:
    paths:
      - dist/*.whl

Jenkins, CircleCI, and other systems work the same way: run the same uv run py2native build ... command inside a shell step. The benefit of putting this in a pipeline is that the exact source patterns, flags, and artifact paths are version-controlled alongside the application.

Monitoring and Failure Handling

A build-only pipeline only helps if you know when it breaks. Set up notifications on failure:

- name: Notify Slack on failure
  if: failure()
  run: |
    curl -s -X POST \
      -H 'Content-type: application/json' \
      --data '{"text":"Py2Native build failed for ${{ github.sha }}"}' \
      "${{ secrets.SLACK_WEBHOOK_URL }}"

Py2Native exits nonzero on a failed compile, link, or wheel build, so your CI system can fail the job naturally.

The most common failure points are environmental rather than code issues:

  • Missing C compiler: install MSVC Build Tools, build-essential, or Xcode Command Line Tools.
  • Unsupported Python version: Py2Native supports CPython 3.11–3.15, including free-threaded 3.14t and 3.15t.
  • Duplicate module names across source files: Py2Native rejects builds where two source files would produce the same module name.
  • Network failures during dependency downloads: Python distributions and libraries are downloaded during the build.

For transient network issues, use a retry action:

- name: Retry compile
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 10
    max_attempts: 3
    command: |
      uv run py2native build --base . --wheel dist/ \
        src/app/main.py src/app/core/*.py src/app/services/*.py

The most useful debugging signal is the compiler and linker output. When Py2Native fails, read the last stage before the nonzero exit code: Cython-to-C generation, C compilation, or linking. That usually tells you whether the problem is a source module, a missing toolchain, or an artifact path.

Best Practices for Automated Python Code Protection

Once the pipeline works, keep it trustworthy.

Lock the build. Commit uv.lock and run uv sync --locked in CI. This prevents a new transitive dependency from changing the build environment between releases.

Treat compiled artifacts as versioned releases. Upload wheels or deployment directories to a secure artifact repository and attach the commit SHA, build number, and date. Never rely on a single developer’s local dist/ directory.

Separate signing from building. Keep the Pro private key in a release-management secret, not in the normal build environment. The build itself should only need the license file and public key. This limits the impact of a leaked CI token.

Rotate license keys and prefer short-lived JWTs. If a license is embedded in each release, a leaked old license should not unlock the next build. Short-lived claims make automation easier to reason about than a never-expiring license.

Test the compiled output before release. A wheel that imports in a clean virtual environment is a much stronger check than a successful compile step. Add at least one import or python -m smoke test.

Update Py2Native explicitly. Monitor new releases and use Dependabot or Renovate to keep py2native and the Pro plugin current. Native build tools change with Python and platform toolchain updates, so scheduled dependency bumps prevent surprise breakage.

FAQ

Can I automate Py2Native builds in any CI/CD system?

Yes. Py2Native is a command-line tool that can be invoked from any CI/CD system that supports running shell commands. You can use GitHub Actions, GitLab CI, Jenkins, CircleCI, or any other platform. The key is to ensure the environment has Python 3.11–3.15, a C compiler, and internet access for downloading dependencies.

How do I handle Pro license verification in an automated pipeline?

For Pro features, you generate a keypair with uv run py2native keygen, sign a JWT payload with the private key to create a license file, and then pass that license file to the build using the --license flag. Store the private key securely in your CI/CD secrets, and ensure the public key is embedded in the binary during compilation. The license file can be generated as part of the pipeline or pre-generated and stored securely.

What are common pitfalls when automating Python code protection?

Common pitfalls include a missing C compiler toolchain, using an unsupported Python version, duplicate module names across source files, and network issues when downloading dependencies. To avoid these, pin your environment, use a lock file for dependencies, and test your pipeline regularly. Py2Native’s clear error messages help diagnose issues quickly.

Does Py2Native work with free-threaded Python (3.14t/3.15t)?

Yes, Py2Native supports CPython 3.11–3.15, including free-threaded builds 3.14t and 3.15t. This means you can automate protection for applications that leverage the experimental no-GIL builds. Ensure your CI environment uses the appropriate Python version.

Conclusion

Code protection should not be a manual release ritual. Py2Native turns custom Python into native machine code with a single uv run py2native build command, which makes it a natural fit for CI/CD. Set up the command once, lock the environment, and let every push produce a protected, testable artifact.

Start by adding py2native to your project and moving your first wheel build into a pipeline. Once that works, add the Pro plugin for license verification and let the binary carry its own public-key check.

Related posts

EU label: AI-generated content