Skip to content

How To: CI/CD Integration

Automate PyLocket protection in your CI/CD pipeline to protect every release automatically.


Authentication in CI/CD

Use an API key and call the REST API. It is the only PyLocket credential that is both durable and revocable, which is what a pipeline needs.

Credential Expires Revocable Suitable for CI
API key Never, until you rotate it Yes, immediately Yes
Session token (JWT from pylocket login) 30 minutes No No, it is dead before your next run
Account password n/a Only by changing it No, see below

Do not put your account password in CI

It grants full account access, not just build permissions; it cannot be scoped or revoked without changing the password everywhere you use it; and non-interactive login fails outright once 2FA is enabled, so the pipeline breaks the day you harden the account.

The pylocket CLI cannot authenticate safely in CI

The CLI only sends Authorization: Bearer and accepts only the 30-minute session token from pylocket login. It has no API-key option, and there is no way to exchange an API key for a session token. That makes the CLI an interactive, local tool. For automation, drive the REST API directly, as below.

Create the key

In the Developer Portal under Settings > API Keys, or:

pylocket auth api-keys rotate

The key is shown once. Store it as a CI secret named PYLOCKET_API_KEY. Rotating issues a new key and invalidates the previous one immediately, which is how you revoke access if a runner is ever compromised.

Send it as the X-API-Key header:

curl -s https://api.pylocket.com/v1/apps -H "X-API-Key: $PYLOCKET_API_KEY"

Send only one credential

The API checks Authorization first and X-API-Key second. If you send both and the JWT is expired or malformed, the request fails with 401 and your API key is never tried. Send X-API-Key on its own.

Your App ID is on each row of the Apps page and in the header of the app's detail page. Click it to copy. Store it as PYLOCKET_APP_ID.

The protect script

Save this as scripts/pylocket-protect.sh in your repository and call it from any CI platform. Keeping it in one file means the platform examples below cannot drift apart from each other.

#!/usr/bin/env bash
# Usage: pylocket-protect.sh <artifact> <platform> <version> <out-dir>
# Requires: PYLOCKET_API_KEY, PYLOCKET_APP_ID, curl, jq
set -euo pipefail

ARTIFACT=$1; PLATFORM=$2; VERSION=$3; OUTDIR=$4
API=${PYLOCKET_API_URL:-https://api.pylocket.com}
AUTH="X-API-Key: ${PYLOCKET_API_KEY}"
BASE="$API/v1/apps/${PYLOCKET_APP_ID}/builds"

# Derive the artifact type from the extension.
case "$ARTIFACT" in
  *.exe) TYPE=exe ;;
  *.elf) TYPE=elf ;;
  *.whl) TYPE=whl ;;
  *.zip) TYPE=zip ;;
  *)     echo "unsupported artifact: $ARTIFACT" >&2; exit 2 ;;
esac

# 1. Register the build and get a presigned upload URL.
CREATE=$(curl -sf -X POST "$BASE" -H "$AUTH" -H 'Content-Type: application/json' \
  -d "{\"version\":\"$VERSION\",\"artifact_type\":\"$TYPE\",\"platform\":\"$PLATFORM\"}")
BUILD_ID=$(echo "$CREATE" | jq -r '.id')
UPLOAD_URL=$(echo "$CREATE" | jq -r '.upload_url')
echo "build $BUILD_ID"

# 2. Upload straight to storage. Content-Type is required or the PUT is refused.
curl -sf -X PUT "$UPLOAD_URL" \
  -H 'Content-Type: application/octet-stream' --upload-file "$ARTIFACT"

# 3. Confirm. This is what queues protection.
curl -sf -X POST "$BASE/$BUILD_ID/confirm" -H "$AUTH" >/dev/null

# 4. Poll. Bounded, so a stuck build fails the job instead of hanging it.
for _ in $(seq 1 80); do
  STATUS=$(curl -sf "$BASE/$BUILD_ID" -H "$AUTH" | jq -r '.status')
  echo "status: $STATUS"
  case "$STATUS" in
    ready)           break ;;
    failed|rejected)
      curl -sf "$BASE/$BUILD_ID" -H "$AUTH" | jq -r '.error_message // "no detail"' >&2
      exit 1 ;;
  esac
  sleep 15
done
[ "$STATUS" = ready ] || { echo "timed out waiting for protection" >&2; exit 1; }

# 5. Download the protected artifact. Report WHY on failure: a bare curl exit
#    code here is the kind of dead end that wastes an afternoon.
mkdir -p "$OUTDIR"
RESP=$(curl -s -w '\n%{http_code}' "$BASE/$BUILD_ID/download" -H "$AUTH")
CODE=${RESP##*$'\n'}; BODY=${RESP%$'\n'*}
if [ "$CODE" != 200 ]; then
  echo "download failed (HTTP $CODE): $(echo "$BODY" | jq -r '.error.message // .detail // .')" >&2
  [ "$(echo "$BODY" | jq -r '.error.reason // empty')" = kyc_required ] && \
    echo "Complete the one-time identity check at https://portal.pylocket.com/settings#kyc" >&2
  exit 1
fi
DL=$(echo "$BODY" | jq -r '.download_url')
curl -sf -o "$OUTDIR/$(basename "$ARTIFACT")" "$DL"
echo "wrote $OUTDIR/$(basename "$ARTIFACT")"

Statuses are lowercase: pending, processing, ready, failed, rejected. See the API Reference for the full endpoint list.

Step 5 needs a verified account

Downloading a protected build requires a one-time identity check. Until it is done, step 5 returns 403 with "reason": "kyc_required" while steps 1-4 succeed, so a pipeline protects a build and then fails only at the download. Complete it once at Settings and the same pipeline works unchanged.

Choosing what gets protected

PyLocket decides which modules are your application code and which are dependencies. If its heuristics miss something — most commonly a package whose entry script lives outside it, the usual src-layout shape — declare it.

Commit a pylocket.toml at the root of your repo:

[protect]
functions = ["myapp.data.ledger_key.ledger_key"]

The CLI picks it up automatically. If you build through the REST API instead, send the same list as force_include when you create the build (see the API reference).

Naming a function rather than a module also asserts it: if that function is not protected, the build fails and tells you which name. Use that as a release gate — it is the difference between finding out now and finding out from a customer.

You can also read back what was protected without unpacking the artifact: GET /v1/apps/{app_id}/builds/{build_id} returns manifest_data carrying functions_protected, modules_protected, modules_skipped, skipped_by_reason and protection_log.

PYLOCKET_FORCE_INCLUDE does not work here

That environment variable is read by the protection engine, which runs on PyLocket's build infrastructure rather than your runner. Setting it in your pipeline has no effect. Use pylocket.toml or the API field above.


GitHub Actions

name: Build, Protect, Release

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

jobs:
  protect:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - platform: linux-x64
            artifact: dist/myapp.elf
            os_label: linux
          - platform: win-x64
            artifact: dist/myapp.exe
            os_label: windows

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install pyinstaller
          pip install -r requirements.txt
          # No pylocket CLI: protection is driven over the REST API with an
          # API key, so no account credential ever reaches the runner.

      - name: Build
        run: |
          pyinstaller --onefile myapp.py
          # Linux onefile output is extensionless; rename to .elf before upload
          if [ "${{ matrix.platform }}" = "linux-x64" ]; then
            mv dist/myapp dist/myapp.elf
          fi

      - name: Protect and download
        env:
          PYLOCKET_API_KEY: ${{ secrets.PYLOCKET_API_KEY }}
          PYLOCKET_APP_ID: ${{ vars.PYLOCKET_APP_ID }}
        run: |
          chmod +x scripts/pylocket-protect.sh
          scripts/pylocket-protect.sh \
            "${{ matrix.artifact }}" "${{ matrix.platform }}" \
            "${GITHUB_REF_NAME#v}" dist/protected/

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: myapp-${{ matrix.os_label }}
          path: dist/protected/

GitLab CI

# .gitlab-ci.yml
stages:
  - build
  - protect
  - release

variables:
  PYTHON_VERSION: "3.12"

build:
  stage: build
  image: python:3.12
  script:
    - pip install pyinstaller -r requirements.txt
    - pyinstaller --onefile myapp.py
  artifacts:
    paths:
      - dist/

protect:
  stage: protect
  image: python:3.12
  # PYLOCKET_API_KEY is a masked, protected CI/CD variable.
  # PYLOCKET_APP_ID can be a plain variable; it is not a secret.
  script:
    - apt-get update -qq && apt-get install -y -qq jq
    # Linux onefile output is extensionless; rename to .elf before upload
    - mv dist/myapp dist/myapp.elf
    - chmod +x scripts/pylocket-protect.sh
    - scripts/pylocket-protect.sh dist/myapp.elf linux-x64 "$CI_COMMIT_TAG" dist/protected/
  artifacts:
    paths:
      - dist/protected/

Jenkins

// Jenkinsfile
pipeline {
    agent any

    environment {
        // Secret-text credential holding the API key. Rotating the key in the
        // Developer Portal revokes this runner's access immediately.
        PYLOCKET_API_KEY = credentials('pylocket-api-key')
        PYLOCKET_APP_ID = '3f8a1c22-9b4e-4d17-a6f0-2c5e7d90b114'
    }

    stages {
        stage('Build') {
            steps {
                sh 'pip install pyinstaller -r requirements.txt'
                sh 'pyinstaller --onefile myapp.py'
            }
        }

        stage('Protect') {
            steps {
                sh '''
                    # Linux onefile output is extensionless; rename to .elf first
                    mv dist/myapp dist/myapp.elf
                    chmod +x scripts/pylocket-protect.sh
                    scripts/pylocket-protect.sh \
                      dist/myapp.elf linux-x64 "${TAG_NAME:-0.0.0}" dist/protected/
                '''
            }
        }
    }

    post {
        success {
            archiveArtifacts artifacts: 'dist/protected/**'
        }
    }
}

Best Practices

Practice Rationale
Store PYLOCKET_API_KEY as a masked secret It is the only credential that is both durable and revocable
Never put your account password in CI It grants full account access and breaks the moment 2FA is enabled
Rotate the key if a runner is compromised Rotation invalidates the previous key immediately
Store PYLOCKET_APP_ID as a variable Makes it easy to change without editing the pipeline
Set a timeout on the status polling loop Prevents infinite waits on failed builds
Bound the polling loop A stuck build should fail the job, not hang the runner
Keep the protect script in your repo One copy per platform is how three examples drift apart

Webhooks — Event-Based Notifications

Instead of polling, configure a webhook to be notified instantly when a build completes, fails, or is rejected, and when you release or unrelease one. This is the recommended approach for build pipelines where protection may take minutes to hours.

Setup

# Configure a webhook for your app
pylocket webhook set --app <APP_ID> --url https://your-server.com/pylocket-hook

# Save the returned secret — it won't be shown again!
# Test the connection
pylocket webhook test --app <APP_ID>

Or in the Developer Portal: the app page's Webhook Notifications section.

Events

Event When
build.completed Protection succeeded — artifact ready for download
build.failed Protection failed — check error_message in payload
build.rejected Artifact flagged by security scanning
build.released You released the build (or a delivery-mode switch with release_current released it). build.released_at and build.delivery_mode are in the payload
build.unreleased You unreleased the build. build.released_at is null

The two release events are sent in either delivery mode, only for a change (releasing a build that is already released sends nothing), and only after the change is saved. Each carries a top-level event_id that is the same on every delivery of that event, so drop an event_id you have already handled.

PyLocket makes up to 3 attempts per event: the second about 2 seconds after the first fails, and the third about 8 seconds after the second fails. What ends an attempt, and what counts as a delivery, depends on the event:

  • build.released and build.unreleased: each attempt ends at most 5 seconds after it starts, whatever your server does, and only the first 64 KiB of your response is read. A 2xx status is a delivery, whatever the body says, even a body that is cut off or cannot be decoded. A retry can start a little later than scheduled when PyLocket is busy.
  • build.completed, build.failed and build.rejected: connecting, sending, and each wait for more of your response time out after 10 seconds, so a response that keeps trickling in keeps the attempt open; your whole response is read. A 2xx whose body is cut off or cannot be decoded (a bad gzip body, say) is a failed attempt and is retried, so your endpoint can receive the same event more than once. These events carry no event_id: use the event name and build.id to recognise a repeat.

Answer with a 2xx and a short, complete body as soon as you have the payload, and do any slow work afterwards.

If 5 attempts in a row of build.released or build.unreleased to your URL fail within 10 minutes, PyLocket pauses those two events for that app (failed attempts of the other three events do not count): each event in the pause is not sent, and the delivery log lists it with an error starting skipped:. Sending resumes once those failures are 10 minutes old, or as soon as a delivery of any event to the URL succeeds; saving a different URL starts afresh. Events skipped during a pause are not sent later.

PyLocket also makes at most 20 attempts of build.released and build.unreleased in any 10 minutes for all of your account's apps together, delivered or not, whatever the URL. An event past that limit is not sent, the delivery log lists it with an error starting skipped:, and it is not sent later; sending resumes as the earlier attempts become 10 minutes old. Saving a new URL does not reset this limit.

Signature Verification

Every webhook includes an X-PyLocket-Signature header signed with your secret:

import hmac, hashlib

def verify(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.{body.decode()}"
    expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

For a complete example with code signing, see Code Signing Guide.


See Also