Receive GitHub webhooks as Flyte runs, gate a merge on a human decision, and mint short-lived GitHub App tokens.

GitHub

Receive GitHub webhooks in Flyte and turn them into runs. The plugin also provides a human review gate for pull requests and GitHub App token minting.

Installation

pip install "flyteplugins-github[app]"

Requires Python 3.10 or later. Install only the extras you use:

Extra Adds Needed for
app fastapi, uvicorn Serving the receiver
review PyGithub review_pr and collect_review_context
auth PyJWT[crypto] mint_installation_token

The receiver

github_webhooks.py
import flyte
from flyte.extras.webhooks import WebhookAppEnvironment, WebhookEvent, run_once
from flyteplugins.github import GitHubProvider, events

# Serves the receiver at /webhook/github and a setup dashboard at /.
# GITHUB_WEBHOOK_SECRET is mounted automatically.
#
# `scopes` lists the repositories to act on. Deliveries from other
# repositories, or with no repository, are acknowledged but not dispatched.
app_env = WebhookAppEnvironment(
    name="github-webhooks",
    providers=[GitHubProvider()],
    scopes=["octo/repo"],
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github[app]"),
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

The provider reads its secret from GITHUB_WEBHOOK_SECRET, which the app mounts automatically.

Register a handler against an event constant, and launch a run with run_once:

github_webhooks.py
@app_env.on_event(events.PullRequest.OPENED)
async def on_pull_request_opened(event: WebhookEvent) -> dict:
    """Launch triage once per pull request.

    Retried and resent deliveries have the same `dedupe_key()`, so `run_once`
    launches only one run for them. Use `await run_once.aio(...)`: the blocking
    form stalls the app's event loop, and GitHub times out a delivery after
    ten seconds.
    """
    import flyte.remote as remote

    task = remote.Task.get(name="github-triage.triage_pr", auto_version="latest")
    result = await run_once.aio(
        task,
        key=event.dedupe_key(),
        repo=event.scope,
        number=event.payload["pull_request"]["number"],
    )
    if not result.created:
        # An earlier delivery of this event already launched a run.
        return {"skipped": result.run.name, "url": result.run.url}
    return {"run": result.run.name, "url": result.run.url}

Serve the app, then copy the payload URL from the dashboard:

github_webhooks.py
if __name__ == "__main__":
    flyte.init_from_config(root_dir=pathlib.Path(__file__).parent)
    deployment = flyte.serve(app_env)
    # The dashboard shows the payload URL to enter in GitHub under
    # Settings -> Webhooks -> Add webhook.
    print(f"Setup dashboard: {deployment.url}")

Set up the webhook in GitHub

In the repository, go to Settings → Webhooks → Add webhook and set:

  1. Payload URL: the /webhook/github URL from the app’s dashboard.
  2. Secret: the value you stored as the github-webhook-secret Flyte secret.
  3. Content type: either option works. The provider handles application/json and the default application/x-www-form-urlencoded identically.
  4. Events: the events your handlers match.

GitHub sends a ping event when you create the webhook. The provider answers it, so a successful first delivery confirms the URL is reachable.

Events

Constants live in flyteplugins.github.events. Use .ANY to match every action on a type, or a specific member to match one action. For an event the constants don’t cover, pass the raw string.

Class Members
PullRequest ANY, OPENED, CLOSED, REOPENED, EDITED, ASSIGNED, UNASSIGNED, LABELED, UNLABELED, SYNCHRONIZE, READY_FOR_REVIEW, CONVERTED_TO_DRAFT, REVIEW_REQUESTED, REVIEW_REQUEST_REMOVED, LOCKED, UNLOCKED
Issues ANY, OPENED, CLOSED, REOPENED, EDITED, ASSIGNED, UNASSIGNED, LABELED, UNLABELED, MILESTONED, DEMILESTONED, PINNED, UNPINNED, LOCKED, UNLOCKED, TRANSFERRED, DELETED
IssueComment ANY, CREATED, EDITED, DELETED
PullRequestReview ANY, SUBMITTED, EDITED, DISMISSED
PullRequestReviewComment ANY, CREATED, EDITED, DELETED
Push, Create, Delete, Fork ANY only. GitHub sends no action for these.
Release ANY, PUBLISHED, UNPUBLISHED, CREATED, EDITED, DELETED, PRERELEASED, RELEASED
WorkflowRun ANY, REQUESTED, IN_PROGRESS, COMPLETED
CheckRun ANY, CREATED, COMPLETED, REREQUESTED, REQUESTED_ACTION
CheckSuite ANY, COMPLETED, REQUESTED, REREQUESTED
Installation ANY, CREATED, DELETED, SUSPEND, UNSUSPEND, NEW_PERMISSIONS_ACCEPTED
InstallationRepositories ANY, ADDED, REMOVED
Star ANY, CREATED, DELETED

Issues is plural to match GitHub’s event name.

PullRequest.CLOSED fires both when a pull request is merged and when it’s closed without merging. Check event.payload["pull_request"]["merged"] to tell them apart.

Scope and deduplication

scope is the repository’s full_name, such as octo/repo. resource_id is owner/repo#number, with the comment or review ID appended when there is one, so separate comments on one issue get separate dedupe keys.

The tasks it launches

Call GitHub from a task with PyGithub:

github_tasks.py
env = flyte.TaskEnvironment(
    name="github-triage",
    image=flyte.Image.from_debian_base().with_pip_packages("PyGithub"),
    # Token for reading the pull request. The webhook secret is mounted on
    # the receiver app, not here.
    secrets=[flyte.Secret(key="github-token", as_env_var="GITHUB_TOKEN")],
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

@env.task
async def triage_pr(repo: str, number: int) -> str:
    """Label a pull request by size."""
    import os

    from github import Auth, Github

    client = Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"]))
    pull = client.get_repo(repo).get_pull(number)
    changed = pull.additions + pull.deletions
    label = "size/s" if changed < 50 else "size/m" if changed < 500 else "size/l"
    pull.add_to_labels(label)
    return label

The token the task uses to read the pull request is a separate credential from the webhook secret. Mount it on the task environment, not the app.

Human review gates

review_pr pauses a run until a person reviews a pull request in the Flyte UI, then returns their decision. It creates a flyte.new_condition carrying the pull request’s metadata and waits on it.

github_tasks.py
review_env = flyte.TaskEnvironment(
    name="github-review",
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github[review]"),
    secrets=[flyte.Secret(key="github-token", as_env_var="GITHUB_TOKEN")],
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

@review_env.task
async def gated_merge(repo: str, number: int) -> str:
    """Wait for a review decision in the Flyte UI, then act on it.

    `review_pr` creates a condition carrying the pull request's metadata and
    waits for a reviewer to answer it. The condition is stored on the backend,
    so the run survives restarts while it waits.
    """
    from flyteplugins.github import review_pr

    decision = await review_pr(repo, number, instructions="Block on missing tests.")
    if decision.is_approved:
        return f"approved by {decision.reviewer}: {decision.summary}"
    blocking = ", ".join(c.path for c in decision.blocking_comments)
    return f"{decision.verdict}: {decision.summary} ({blocking})"

The condition is stored on the backend, so the run can wait for days and survives restarts.

review_pr returns a ReviewDecision:

Field Type Description
verdict "approve" | "request_changes" | "comment" The reviewer’s verdict
summary str The reviewer’s summary
comments list[ReviewComment] Each has path, line, body, and a severity of info, warning, or blocking
reviewer str | None Who reviewed
is_approved bool True when verdict == "approve"
blocking_comments list[ReviewComment] Comments with blocking severity

review_pr also accepts:

  • condition_name: defaults to a name derived from the repository and pull request number.
  • instructions: replaces the default reviewer prompt.
  • timeout: passed to flyte.new_condition. On expiry, wait() raises flyte.errors.ConditionTimedoutError.
  • max_files: the maximum number of changed files to include.
  • token: the GitHub token used to read the pull request.

review_pr reads the pull request with PyGithub, so it requires the review extra.

GitHub App tokens

To clone, push, or open pull requests from a task, authenticate as a GitHub App rather than with a personal access token. mint_installation_token creates an installation token that expires after one hour.

github_tasks.py
agent_env = flyte.TaskEnvironment(
    name="github-agent",
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github[auth]"),
    # Each key maps to the environment variable `mint_installation_token`
    # reads (github-app-id -> GITHUB_APP_ID), so `as_env_var=` isn't needed.
    secrets=[
        flyte.Secret(key="github-app-id"),
        flyte.Secret(key="github-app-installation-id"),
        flyte.Secret(key="github-app-private-key"),
    ],
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

@agent_env.task
async def clone_at_head(repo: str) -> str:
    """Build an authenticated clone URL with a GitHub App installation token.

    The token expires after one hour.
    """
    import asyncio

    from flyteplugins.github import clone_url, mint_installation_token

    # mint_installation_token is synchronous; run it off the event loop.
    # It returns None if the App credentials are missing.
    token = await asyncio.to_thread(mint_installation_token)
    url = clone_url(repo, token)
    # Never return or log the token itself.
    return url.replace(token, "***") if token else url

Store three values as Flyte secrets:

Secret Where to find it
github-app-id The app’s General tab, under App ID. Not the Client ID.
github-app-installation-id The URL after you install the app: .../settings/installations/<id>
github-app-private-key The .pem file from Generate a private key. GitHub shows it only once.
flyte create secret github-app-id --value 1234567
flyte create secret github-app-installation-id --value 87654321
flyte create secret github-app-private-key --from-file ~/Downloads/app.private-key.pem

Use --from-file for the private key. Passing the multi-line PEM through --value can lose its newlines, and the mint then fails.

Each secret’s key maps to the environment variable the function reads, so you don’t need as_env_var=. If the App secrets aren’t set, the function falls back to GITHUB_TOKEN or GH_TOKEN.

mint_installation_token returns None instead of raising when credentials are missing, so check the result before using it. It’s synchronous; call it through asyncio.to_thread from async code, as in the example.

Test without a GitHub account

github_tasks.py
# A separate environment with no secrets, so the replay runs before any
# secret is created.
replay_env = flyte.TaskEnvironment(
    name="github-replay",
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github"),
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

@replay_env.task
async def replay_sample_delivery() -> dict[str, str]:
    """Verify and parse the sample delivery bundled with the plugin.

    `SAMPLE_DELIVERY` is a recorded GitHub payload and a function that signs
    it. The body is real; the signature header is generated by the plugin.
    """
    import flyteplugins.github as plugin

    secret = "a-test-signing-secret"
    sign, body = plugin.SAMPLE_DELIVERY
    headers = sign(body, secret)

    assert plugin.verify(body, headers, secret), "a correctly signed delivery must verify"
    assert not plugin.verify(body, headers, "wrong-secret"), "a bad signature must not"

    event = plugin.parse(headers, body)
    return {
        # Matched by `on_event`. `events.PullRequest.OPENED` equals this string.
        "qualified_type": event.qualified_type,
        # Matched against `scopes`.
        "scope": event.scope or "",
        "title": event.title or "",
        "actor": event.actor or "",
        # The key `run_once` deduplicates on.
        "dedupe_key": event.dedupe_key(),
    }
flyte run --local github_tasks.py replay_sample_delivery

Examples

Both files are in v2/integrations/flyte-plugins/github:

  • github_webhooks.py: the receiver, its handler, and serving it.
  • github_tasks.py: size labeling with PyGithub, the review_pr gate, App token minting, and the offline replay.

See also