DevLab / deployment / github-actions-workflow-trigger
DEPLOYMENT PYTHON BEGINNER

GitHub Actions Workflow Trigger

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

Python3.10+
OSLinux / macOS
Lines~179
Packages1 deps
Install Dependencies
$ pip install requests
Usage
$ python3 gh_workflow.py --repo owner/repo --workflow deploy.yml --ref main
#deployment#python#beginner#requests

GitHub Actions Workflow Trigger

Problem Statement

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).

How It Works

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.

Full Script

#!/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())

Configuration

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.

Usage Examples

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

Error Handling

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.
  • Network errors (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.

Extending Ideas

  • Poll for run status. After a successful dispatch, use 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.
  • Batch triggers. Read a list of repos from a YAML file and trigger the same workflow across many repositories (e.g. rolling out a config change).
  • Retry with backoff. Wrap trigger_workflow in a retry decorator that handles 5xx and rate-limit (403 with X-RateLimit-Remaining: 0) responses.
  • Slack/Teams notification. After triggering, post the run URL to a webhook so the team knows a deployment is in flight.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com