CI/CD pipelines often need to be kicked off from outside of GitHub itself. Maybe you have a nightly cron job on a legacy server that needs to trigger a dep
CI/CD pipelines often need to be kicked off from outside of GitHub itself. Maybe you have a nightly cron job on a legacy server that needs to trigger a deployment workflow, or a monitoring system that should re-run a build when a dependency updates. Clicking through the GitHub UI isn't an option when you need automation, and webhooks only work one direction (GitHub → you), not the other way around.
This script solves that by wrapping the GitHub REST API endpoint POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches in a friendly CLI. It handles authentication, error reporting, and the small gotchas of the API (like needing workflow_dispatch to be enabled in the workflow file, and the fact that the API returns 204 No Content on success — not a JSON body you can inspect).
The script takes three required arguments — --repo, --workflow, and --ref — plus optional --inputs for passing parameters to the workflow. It builds the correct API URL, attaches a Bearer token from the GITHUB_TOKEN environment variable, and POSTs a small JSON payload to GitHub's Actions API.
The design is intentionally simple: a single trigger_workflow() function does the HTTP work and raises typed exceptions on failure, while main() handles argparse, environment validation, and user-facing output. This separation means you can import trigger_workflow into another script without dragging in the CLI layer. We use requests for the HTTP call because it handles connection pooling, TLS, and JSON serialization cleanly — no need to reinvent urllib boilerplate.
#!/usr/bin/env python3
"""
GitHub Actions Workflow Trigger
Trigger a GitHub Actions workflow via the `workflow_dispatch` API endpoint.
Requires a Personal Access Token (or fine-grained token) with the
`actions:write` permission, supplied via the GITHUB_TOKEN env var.
Usage:
python3 gh_workflow.py --repo owner/repo --workflow deploy.yml --ref main
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
import requests
# ---------------------------------------------------------------------------
# Configuration — override with environment variables
# ---------------------------------------------------------------------------
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") # required at runtime
REQUEST_TIMEOUT = int(os.environ.get("REQUEST_TIMEOUT", "30")) # seconds
class WorkflowTriggerError(RuntimeError):
"""Raised when the GitHub API rejects or fails to process the request."""
def trigger_workflow(
repo: str,
workflow: str,
ref: str,
inputs: dict[str, Any] | None = None,
*,
token: str | None = None,
api_base: str = GITHUB_API_BASE,
timeout: int = REQUEST_TIMEOUT,
) -> None:
"""
Trigger a workflow_dispatch event for the given workflow.
Args:
repo: "owner/repo" string.
workflow: Workflow file name (e.g. "deploy.yml") or numeric workflow ID.
ref: Git ref (branch, tag, or SHA) to run the workflow against.
inputs: Optional dict of workflow inputs (must match `on.workflow_dispatch.inputs`).
token: GitHub token; falls back to the GITHUB_TOKEN env var.
api_base: Base URL for the GitHub API (override for GH Enterprise).
timeout: HTTP timeout in seconds.
Raises:
WorkflowTriggerError: on any non-2xx API response.
"""
token = token or GITHUB_TOKEN
if not token:
raise WorkflowTriggerError(
"No GitHub token found. Set the GITHUB_TOKEN environment variable."
)
if "/" not in repo:
raise WorkflowTriggerError(
f"--repo must be in 'owner/repo' form, got: {repo!r}"
)
# The API expects the workflow file name (e.g. deploy.yml) or numeric ID.
# Passing a full path like .github/workflows/deploy.yml also works, but
# the short name is cleaner and what most users expect.
url = f"{api_base}/repos/{repo}/actions/workflows/{workflow}/dispatches"
payload: dict[str, Any] = {"ref": ref}
if inputs:
payload["inputs"] = inputs
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
}
try:
response = requests.post(
url,
headers=headers,
data=json.dumps(payload),
timeout=timeout,
)
except requests.RequestException as exc:
# Network-level failure: DNS, TLS, timeout, connection reset, etc.
raise WorkflowTriggerError(f"HTTP request failed: {exc}") from exc
# Success is 204 No Content — there's no body to parse.
if response.status_code == 204:
return
# Anything else is an error. Try to pull GitHub's structured message.
message = response.text
try:
body = response.json()
message = body.get("message", message)
except ValueError:
pass # Response wasn't JSON; keep the raw text.
# Give the user a hint for the most common failure modes.
hints = {
401: "Check that GITHUB_TOKEN is valid and not expired.",
403: "Token lacks 'actions:write' scope, or the repo blocks it.",
404: "Repo/workflow/ref not found, or workflow lacks `workflow_dispatch`.",
422: "Ref is invalid or workflow inputs don't match the schema.",
}
hint = hints.get(response.status_code, "")
detail = f"{response.status_code} {message}"
if hint:
detail += f" — {hint}"
raise WorkflowTriggerError(detail)
def _parse_inputs(raw: str | None) -> dict[str, Any] | None:
"""Parse a --inputs value: either a JSON object or key=value pairs."""
if not raw:
return None
raw = raw.strip()
if raw.startswith("{"):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"Invalid JSON for --inputs: {exc}") from exc
if not isinstance(parsed, dict):
raise argparse.ArgumentTypeError("--inputs JSON must be an object")
return parsed
# Fallback: comma-separated key=value pairs, e.g. "env=prod,debug=true"
result: dict[str, Any] = {}
for pair in raw.split(","):
if "=" not in pair:
raise argparse.ArgumentTypeError(
f"--inputs entry must be key=value, got: {pair!r}"
)
key, _, value = pair.partition("=")
# Coerce booleans so workflow_dispatch type validation passes.
lowered = value.strip().lower()
if lowered in ("true", "false"):
result[key.strip()] = lowered == "true"
else:
result[key.strip()] = value.strip()
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Trigger a GitHub Actions workflow via workflow_dispatch.",
)
parser.add_argument(
"--repo",
required=True,
help="Repository in 'owner/repo' form (e.g. acme/api).",
)
parser.add_argument(
"--workflow",
required=True,
help="Workflow file name (e.g. deploy.yml) or numeric workflow ID.",
)
parser.add_argument(
"--ref",
required=True,
help="Git ref to run against (branch, tag, or SHA).",
)
parser.add_argument(
"--inputs",
default=None,
help='Workflow inputs as JSON ({"env":"prod"}) or key=value pairs (env=prod,debug=true).',
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress success output (useful in scripts).",
)
args = parser.parse_args(argv)
try:
inputs = _parse_inputs(args.inputs)
except argparse.ArgumentTypeError as exc:
parser.error(str(exc))
return 2 # unreachable, but keeps type checkers happy
try:
trigger_workflow(args.repo, args.workflow, args.ref, inputs)
except WorkflowTriggerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if not args.quiet:
print(
f"✓ Triggered {args.workflow} on {args.repo}@{args.ref}"
)
if inputs:
print(f" inputs: {json.dumps(inputs)}")
print(f" view runs: https://github.com/{args.repo}/actions")
return 0
if __name__ == "__main__":
sys.exit(main())
| Variable | Required | Default | Purpose |
|---|---|---|---|
GITHUB_TOKEN |
Yes | — | Personal access token or fine-grained token with actions:write. The script refuses to run without it. |
GITHUB_API_BASE |
No | https://api.github.com |
Override for GitHub Enterprise Server (e.g. https://ghe.corp.com/api/v3). |
REQUEST_TIMEOUT |
No | 30 |
HTTP timeout in seconds. Bump for slow networks. |
Creating a token: For classic PATs, enable the workflow scope (and repo for private repos). For fine-grained tokens, grant Actions: Read and write on the target repository.
Enabling workflow_dispatch: The workflow file must contain:
on:
workflow_dispatch:
inputs:
env:
description: "Target environment"
required: true
Without this trigger block, the API returns 404 even if the workflow file exists.
1. Trigger a deployment workflow on main:
$ export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
$ python3 gh_workflow.py --repo acme/api --workflow deploy.yml --ref main
✓ Triggered deploy.yml on acme/api@main
view runs: https://github.com/acme/api/actions
2. Pass workflow inputs as key=value pairs:
$ python3 gh_workflow.py \
--repo acme/api \
--workflow deploy.yml \
--ref main \
--inputs env=production,debug=false
✓ Triggered deploy.yml on acme/api@main
inputs: {"env": "production", "debug": false}
3. Pass inputs as JSON, silently (for scripting):
$ python3 gh_workflow.py \
--repo acme/api \
--workflow nightly.yml \
--ref release/2.1 \
--inputs '{"suite":"integration","retries":3}' \
--quiet
$ echo $?
0
The script distinguishes between network failures and API rejections, and maps common HTTP status codes to actionable hints:
401 Unauthorized — token missing, malformed, or expired.403 Forbidden — token lacks actions:write scope, or the repo has Actions disabled.404 Not Found — repo/workflow/ref doesn't exist, or the workflow file is missing on: workflow_dispatch.422 Unprocessable Entity — the ref is invalid, or the inputs don't match the workflow's declared input schema.requests.RequestException) — DNS failures, TLS errors, and timeouts are caught and re-raised as WorkflowTriggerError with the original exception chained.All errors exit with status 1 and print a message to stderr. Success exits 0. This makes the script safe to use in shell pipelines with set -e.
GET /repos/{owner}/{repo}/actions/runs?event=workflow_dispatch to find the newly created run and poll its status until completion — useful for gating downstream steps.trigger_workflow in a retry decorator that handles 5xx and rate-limit (403 with X-RateLimit-Remaining: 0) responses.