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
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:
@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:
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:
- Payload URL: the
/webhook/githubURL from the app’s dashboard. - Secret: the value you stored as the
github-webhook-secretFlyte secret. - Content type: either option works. The provider handles
application/jsonand the defaultapplication/x-www-form-urlencodedidentically. - 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:
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.
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 toflyte.new_condition. On expiry,wait()raisesflyte.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.
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.pemUse --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
# 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_deliveryExamples
Both files are in v2/integrations/flyte-plugins/github:
github_webhooks.py: the receiver, its handler, and serving it.github_tasks.py: size labeling withPyGithub, thereview_prgate, App token minting, and the offline replay.
See also
- Software development tools for the event model,
run_once, and scopes. - Review and release gates for what to build on top of this.
- GitHub API reference.