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:
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:
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¶
- Code Signing — Sign protected apps for macOS and Windows
- Advanced Tutorial — CI/CD overview
- CLI Reference — All command options
- Cross-Platform Builds — Multi-platform CI/CD matrix