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.

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. 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 via the Developer Portal: App Settings → Webhooks.

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

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