Automating Patcher¶

Running Patcher on a schedule or in CI/CD pipelines.


Create a LaunchAgent on a workstation for time-of-day scheduling, or run Patcher non-interactively with GitHub Actions.

In-memory credentials

Passed at runtime and never written to the keychain.

No prompts

Setup, Installomator, and UI prompts are skipped. Defaults apply instead.

Runs right away

The command runs as soon as a token is fetched. No wizard, no waiting.

Scheduling Locally¶

On a workstation, a LaunchAgent runs Patcher on a schedule. It hands scheduling to macOS and writes stdout/stderr to log files you can tail when something misbehaves.

Warning

Make sure both python3 and patcherctl are on your PATH. When you install via PyPI, patcherctl lands in your Python user-base bin directory. See Adding to Environment Path if patcherctl --version fails to resolve.

Build the property list file

Customize the example property list below to fit your needs. Specifically, be sure to adjust paths and flags under ProgramArguments to match what you’d run by hand. StartCalendarInterval configures the schedule the agent will run. Reference Launched as it is a great helper for building these.

~/Library/LaunchAgents/com.liquidzoo.patcher.plist¶
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>com.liquidzoo.patcher-export.plist</string>
    <key>ProgramArguments</key>
    <array>
      <string>sh</string>
      <string>-c</string>
      <string>patcherctl export --path /path/to/save --format pdf</string>
    </array>
    <key>StandardErrorPath</key>
    <string>$HOME/Library/Application Support/Patcher/logs/patcher-agent.err.log</string>
    <key>StandardOutPath</key>
    <string>$HOME/Library/Application Support/Patcher/logs/patcher-agent.out.log</string>
    <key>StartCalendarInterval</key>
    <array>
      <dict>
        <key>Day</key>
        <integer>1</integer>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
      </dict>
    </array>
  </dict>
</plist>

Deploy and load

$ cp com.liquidzoo.patcher-export.plist ~/Library/LaunchAgents/
$ chmod 644 ~/Library/LaunchAgents/com.liquidzoo.patcher-export.plist
$ launchctl load ~/Library/LaunchAgents/com.liquidzoo.patcher-export.plist

Verify it’s active

$ launchctl list | grep com.liquidzoo.patcher-export

Test the Configuration¶

Manually run the export command.

Manually run patcherctl export command to confirm it executes as expected.

Check the logs.

Check the logs for errors or confirmation of success.

Standard Output

~/Library/Application Support/Patcher/logs/patcher-agent.out.log

Standard Error

~/Library/Application Support/Patcher/logs/patcher-agent.err.log

CI/CD & Non-Interactive Mode¶

On a CI runner or build server, Patcher runs in non-interactive mode. It reads credentials from flags or environment variables, skips every prompt, and keeps no keychain access or saved state.

Definition: ephemeral runner

A CI worker created fresh for a single job and destroyed when it finishes, like a GitHub Actions runner or a short-lived container. It keeps no state between runs, so there’s no saved keychain or config to read. That’s exactly why Patcher takes credentials from flags or environment variables here.

CLI flag

Environment variable

Description

--client-id

PATCHER_CLIENT_ID

Jamf Pro API client ID

--client-secret

PATCHER_CLIENT_SECRET

Jamf Pro API client secret

--url

PATCHER_URL

Jamf Pro instance URL

Important

Credentials can be set via command line flags or environment variables. If both are used, command line flags take precedence.

Linux and Keyring¶

Linux runners do not have a built-in keyring backend by default. To handle this, Patcher automatically detects which platform it is being invoked on, and accordingly installs a null backend automatically. CI runners, containers, and Linux cron jobs just work with no setup. To force a specific backend, set KEYRING_BACKEND (Patcher won’t override one you’ve already set).

Quick Example¶

$ patcherctl \
  --client-id="abc-123" \
  --client-secret="my-secret" \
  --url="https://my.jamfcloud.com" \
  export --path=/tmp/reports --format=json
$ export PATCHER_CLIENT_ID=abc-123
$ export PATCHER_CLIENT_SECRET=my-secret
$ export PATCHER_URL=https://my.jamfcloud.com

$ patcherctl export --path=/tmp/reports --format=json

GitHub Actions Workflow¶

Runs Patcher on a schedule and uploads the JSON report as a build artifact. Adjust schedule, output path, and retention to fit your needs.

.github/workflows/patch-report.yml¶
name: Patch Report

on:
  schedule:
    - cron: '0 13 * * 1-5'  # Weekdays at 13:00 UTC
  workflow_dispatch:

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

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

      - name: Install Patcher
        run: pip install patcherctl

      - name: Generate patch report
        env:
          PATCHER_CLIENT_ID: ${{ secrets.JAMF_CLIENT_ID }}
          PATCHER_CLIENT_SECRET: ${{ secrets.JAMF_CLIENT_SECRET }}
          PATCHER_URL: ${{ secrets.JAMF_URL }}
        run: |
          mkdir -p ./reports
          patcherctl export --path=./reports --format=json

      - uses: actions/upload-artifact@v4
        with:
          name: patch-report
          path: ./reports/*.json
          retention-days: 30

JSON pairs well with downstream automation. Feed it into a job that posts to Slack, ingests into a dashboard, or triggers patching policies based on coverage thresholds.

Library Equivalent¶

The CLI invocation in the workflow above is a convenience over the library API. Drop in a Python script if you’d rather build the report logic in-process. This can be useful when you want to filter or transform titles before exporting, or you’re integrating Patcher into an existing automation.

Library callers that pass credentials directly to PatcherClient(...) bypass the keyring on every platform.¶
import asyncio
import os
from pathlib import Path

from patcher import PatcherClient


async def main() -> None:
    async with PatcherClient(
        client_id=os.environ["PATCHER_CLIENT_ID"],
        client_secret=os.environ["PATCHER_CLIENT_SECRET"],
        server=os.environ["PATCHER_URL"],
        disable_cache=True,  # CI runner, no on-disk cache
    ) as patcher:
        titles = await patcher.fetch_patches(sort_by="released")
        await patcher.export(
            titles,
            output_dir=Path("./reports"),
            formats={"json"},
        )


asyncio.run(main())

What’s Not Supported in Non-Interactive Mode¶

Resetting credentials via reset

Designed for keychain workflows. In CI, update the secrets and re-run.

Interactive setup (--fresh)

Non-interactive mode skips the setup state machine entirely, so this flag does nothing.

Customization prompts

PDF header, footer, and logo use built-in defaults. To customize the PDF, configure UI settings on a workstation first and commit the plist values to your CI image, or generate JSON and style it downstream.