flyteplugins-github 2.7.2__py3-none-any.whl → 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,14 +48,31 @@ It lives here because the condition is the part only Flyte can do. Reading the
48
48
  pull request is `PyGithub`'s job, and this calls it directly rather than
49
49
  wrapping it — install `flyteplugins-github[review]` for that extra.
50
50
 
51
- Calling the GitHub API for anything else is not this plugin's job either; use
51
+ ## GitHub App tokens
52
+
53
+ Agents that clone, push, or open PRs authenticate best as a GitHub App,
54
+ minting a short-lived installation token per operation instead of holding a
55
+ personal access token:
56
+
57
+ ```python
58
+ from flyteplugins.github import clone_url, mint_installation_token
59
+
60
+ token = mint_installation_token() # GITHUB_APP_ID / _INSTALLATION_ID / _PRIVATE_KEY
61
+ url = clone_url("octo/repo", token)
62
+ ```
63
+
64
+ It lives here because every agent otherwise carries its own copy of the same
65
+ minting logic — install `flyteplugins-github[auth]` for that extra.
66
+
67
+ Wrapping the GitHub API for anything else is not this plugin's job; use
52
68
  `PyGithub` from your tasks. See `examples/external_saas_integrations`.
53
69
  """
54
70
 
55
71
  import hashlib
56
72
  import hmac
57
73
 
58
- from . import events
74
+ from . import events, payloads
75
+ from ._app_auth import clone_url, mint_installation_token
59
76
  from ._provider import GitHubProvider, handshake, parse, verify
60
77
  from ._review import (
61
78
  DEFAULT_TOKEN_ENV_VAR,
@@ -79,12 +96,15 @@ __all__ = [
79
96
  "ReviewDecision",
80
97
  "Verdict",
81
98
  "build_review_prompt",
99
+ "clone_url",
82
100
  "collect_review_context",
83
101
  "condition_name_for",
84
102
  "events",
85
103
  "handshake",
104
+ "mint_installation_token",
86
105
  "parse",
87
106
  "parse_review_payload",
107
+ "payloads",
88
108
  "review_pr",
89
109
  "verify",
90
110
  ]
@@ -0,0 +1,146 @@
1
+ """GitHub App installation tokens, for agents that clone, push, or open PRs.
2
+
3
+ The pattern: hold no personal access token. Authenticate as a GitHub App and
4
+ mint a short-lived installation token whenever one is needed. Tokens live one
5
+ hour — plenty for a clone or a `gh pr create`, useless to an attacker who
6
+ exfiltrates one from a log.
7
+
8
+ This belongs in the plugin because every agent otherwise carries its own copy
9
+ of the same fifty lines: sign an RS256 JWT as the app, trade it for an
10
+ installation token, splice it into a clone URL. The webhook side of a GitHub
11
+ agent already imports this package, so the auth side comes from it too.
12
+
13
+ Inputs, all injected as Flyte secrets (or passed explicitly):
14
+
15
+ GITHUB_APP_ID the app's numeric id
16
+ GITHUB_APP_INSTALLATION_ID the installation's numeric id
17
+ GITHUB_APP_PRIVATE_KEY the app's PEM private key
18
+
19
+ The first two come off the app's *General* tab (App ID — not the Client ID
20
+ beside it) and the URL you land on after installing the app,
21
+ `.../settings/installations/<id>`. The third is the `.pem` from *Generate a
22
+ private key*, which GitHub shows exactly once. Store them with:
23
+
24
+ ```bash
25
+ flyte create secret github-app-id --value 1234567
26
+ flyte create secret github-app-installation-id --value 87654321
27
+ flyte create secret github-app-private-key --from-file ~/Downloads/app.private-key.pem
28
+ ```
29
+
30
+ `--from-file` because a PEM is multi-line and a shell that eats the newlines
31
+ yields a key that fails to parse here, at mint time. Mounted on a
32
+ `TaskEnvironment` as `flyte.Secret("github-app-private-key")`, the kebab-case
33
+ name upper-cases into the env var above with no `as_env_var=` needed. The
34
+ README's "GitHub App tokens" section walks through all of it, including
35
+ listing installation ids over the API instead of hunting for the URL.
36
+
37
+ `GITHUB_TOKEN` and `GH_TOKEN` are honored as fallbacks so a deployment can
38
+ migrate one secret at a time; once the app secrets exist the fallback never
39
+ fires.
40
+
41
+ `PyJWT[crypto]` signs the app JWT, so a webhook-only install stays lean:
42
+
43
+ ```bash
44
+ pip install "flyteplugins-github[auth]"
45
+ ```
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import json
51
+ import logging
52
+ import os
53
+ import time
54
+ import urllib.request
55
+ from typing import Any
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ GITHUB_API = "https://api.github.com"
60
+ #: App tokens authenticate with this literal username in clone URLs.
61
+ GIT_USERNAME = "x-access-token"
62
+
63
+ #: Environment variables the app credentials default to.
64
+ DEFAULT_APP_ID_ENV = "GITHUB_APP_ID"
65
+ DEFAULT_INSTALLATION_ID_ENV = "GITHUB_APP_INSTALLATION_ID"
66
+ DEFAULT_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY"
67
+
68
+ #: Plain-token fallbacks, checked in order when the app credentials are absent.
69
+ FALLBACK_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
70
+
71
+
72
+ def _post_json(url: str, *, bearer: str) -> dict[str, Any]:
73
+ """POST to the GitHub API with a bearer credential and return the JSON."""
74
+ request = urllib.request.Request(
75
+ url,
76
+ method="POST",
77
+ headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"},
78
+ )
79
+ with urllib.request.urlopen(request, timeout=30) as response:
80
+ return json.loads(response.read().decode("utf-8"))
81
+
82
+
83
+ def mint_installation_token(
84
+ *,
85
+ app_id: str | None = None,
86
+ installation_id: str | None = None,
87
+ private_key: str | None = None,
88
+ ) -> str | None:
89
+ """A fresh installation token, or None with a logged reason.
90
+
91
+ None means "proceed unauthenticated or not at all" — treat it the way a
92
+ missing token is treated today, so a half-configured deployment degrades
93
+ instead of crashing. Synchronous, one HTTPS round trip: call through
94
+ `asyncio.to_thread` from handlers and other async code.
95
+
96
+ Args:
97
+ app_id: The app's numeric id; otherwise read from `GITHUB_APP_ID`.
98
+ installation_id: The installation's numeric id; otherwise read from
99
+ `GITHUB_APP_INSTALLATION_ID`.
100
+ private_key: The app's PEM private key; otherwise read from
101
+ `GITHUB_APP_PRIVATE_KEY`.
102
+ """
103
+ app_id = app_id or os.environ.get(DEFAULT_APP_ID_ENV)
104
+ installation_id = installation_id or os.environ.get(DEFAULT_INSTALLATION_ID_ENV)
105
+ private_key = private_key or os.environ.get(DEFAULT_PRIVATE_KEY_ENV)
106
+
107
+ if not (app_id and installation_id and private_key):
108
+ for env in FALLBACK_TOKEN_ENVS:
109
+ fallback = os.environ.get(env)
110
+ if fallback:
111
+ logger.info("GitHub App credentials not set; using %s fallback", env)
112
+ return fallback
113
+ logger.warning(
114
+ "Neither the %s/%s/%s secrets nor a fallback token (%s) are set; "
115
+ "authenticated GitHub operations will be skipped",
116
+ DEFAULT_APP_ID_ENV,
117
+ DEFAULT_INSTALLATION_ID_ENV,
118
+ DEFAULT_PRIVATE_KEY_ENV,
119
+ "/".join(FALLBACK_TOKEN_ENVS),
120
+ )
121
+ return None
122
+
123
+ try:
124
+ import jwt # PyJWT[crypto]
125
+ except ModuleNotFoundError as exc: # pragma: no cover - depends on extras
126
+ raise ModuleNotFoundError(
127
+ "PyJWT is not installed. Install 'flyteplugins-github[auth]' to mint GitHub App tokens."
128
+ ) from exc
129
+
130
+ now = int(time.time())
131
+ # iat is backdated 60s because GitHub rejects JWTs it considers issued in
132
+ # the future, and clocks drift.
133
+ app_jwt = jwt.encode({"iat": now - 60, "exp": now + 600, "iss": app_id}, private_key, algorithm="RS256")
134
+ try:
135
+ data = _post_json(f"{GITHUB_API}/app/installations/{installation_id}/access_tokens", bearer=app_jwt)
136
+ return data["token"]
137
+ except Exception as exc: # callers degrade, they don't crash
138
+ logger.warning("Could not mint a GitHub App installation token: %s", exc)
139
+ return None
140
+
141
+
142
+ def clone_url(repo: str, token: str | None = None) -> str:
143
+ """An https clone URL for `repo` ("owner/name"), authenticated when a token is given."""
144
+ if token:
145
+ return f"https://{GIT_USERNAME}:{token}@github.com/{repo}.git"
146
+ return f"https://github.com/{repo}.git"
@@ -1,7 +1,20 @@
1
- """GitHub webhook verification and payload normalization."""
1
+ """GitHub webhook verification and payload normalization.
2
+
3
+ GitHub delivers the same payload in two body shapes, chosen per webhook in the
4
+ *Add webhook* form and signed the same way:
5
+
6
+ * content type `application/json` — the JSON is the body;
7
+ * content type `application/x-www-form-urlencoded` — the form's *default* —
8
+ the JSON arrives under a `payload=` form field.
9
+
10
+ `verify` covers both, since the HMAC signs the raw body regardless of encoding.
11
+ `parse` normalizes both into the same `WebhookEvent`, so a webhook left on the
12
+ default content type still works.
13
+ """
2
14
 
3
15
  from __future__ import annotations
4
16
 
17
+ import urllib.parse
5
18
  from typing import Any, ClassVar, Mapping
6
19
 
7
20
  from flyte.extras.webhooks import (
@@ -23,6 +36,30 @@ def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
23
36
  return constant_time_equals(hex_hmac_sha256(secret, body), signature.removeprefix("sha256="))
24
37
 
25
38
 
39
+ def _form_payload(body: bytes) -> dict[str, Any] | None:
40
+ """Decode a form-encoded delivery's `payload` field, or None when the body is JSON.
41
+
42
+ GitHub's *Add webhook* form defaults the content type to
43
+ `application/x-www-form-urlencoded`, which wraps the JSON in a `payload=`
44
+ form field. Sniffing the body rather than trusting Content-Type keeps
45
+ `parse` a pure function of the delivery, which is what the conformance
46
+ harness replays.
47
+ """
48
+ if body[:1] in (b"{", b"["):
49
+ return None
50
+ try:
51
+ decoded = body.decode("utf-8")
52
+ except UnicodeDecodeError:
53
+ return None
54
+ if "=" not in decoded.split("&", 1)[0]:
55
+ return None
56
+ fields = {key: values[0] for key, values in urllib.parse.parse_qs(decoded, keep_blank_values=True).items()}
57
+ raw = fields.get("payload")
58
+ if raw is None:
59
+ raise SignatureError("form-encoded delivery carries no `payload` field")
60
+ return json_body(raw.encode("utf-8"))
61
+
62
+
26
63
  def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
27
64
  """Answer the `ping` GitHub sends when a webhook is created."""
28
65
  if lower_headers(headers).get("x-github-event") == "ping":
@@ -31,12 +68,13 @@ def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
31
68
 
32
69
 
33
70
  def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
34
- """Normalize a GitHub delivery into a `WebhookEvent`."""
71
+ """Normalize a GitHub delivery — JSON or form-encoded — into a `WebhookEvent`."""
35
72
  lowered = lower_headers(headers)
36
73
  event_type = lowered.get("x-github-event")
37
74
  if not event_type:
38
75
  raise SignatureError("missing X-GitHub-Event header")
39
- payload = json_body(body)
76
+ form = _form_payload(body)
77
+ payload = form if form is not None else json_body(body)
40
78
 
41
79
  repo = payload.get("repository") or {}
42
80
  issue_or_pr = payload.get("pull_request") or payload.get("issue") or {}
@@ -77,6 +115,9 @@ class GitHubProvider(Provider):
77
115
  app_env = WebhookAppEnvironment(name="webhooks", providers=[GitHubProvider()])
78
116
  ```
79
117
 
118
+ Either content type in GitHub's *Add webhook* form works: `application/json`
119
+ and the default `application/x-www-form-urlencoded` normalize identically.
120
+
80
121
  `WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
81
122
  need naming again in `secrets=`.
82
123
 
@@ -10,6 +10,8 @@ __all__ = [
10
10
  "Create",
11
11
  "Delete",
12
12
  "Fork",
13
+ "Installation",
14
+ "InstallationRepositories",
13
15
  "IssueComment",
14
16
  "Issues",
15
17
  "PullRequest",
@@ -159,6 +161,29 @@ class CheckSuite(EventType):
159
161
  REREQUESTED = "check_suite.rerequested"
160
162
 
161
163
 
164
+ class Installation(EventType):
165
+ """`installation` events — the app itself was installed, removed, or re-permissioned.
166
+
167
+ Every GitHub App webhook receives these, whatever repository events it
168
+ subscribes to. No repository in the payload, so they dedupe per delivery.
169
+ """
170
+
171
+ ANY = "installation"
172
+ CREATED = "installation.created"
173
+ DELETED = "installation.deleted"
174
+ SUSPEND = "installation.suspend"
175
+ UNSUSPEND = "installation.unsuspend"
176
+ NEW_PERMISSIONS_ACCEPTED = "installation.new_permissions_accepted"
177
+
178
+
179
+ class InstallationRepositories(EventType):
180
+ """`installation_repositories` events — repositories granted to or removed from the app."""
181
+
182
+ ANY = "installation_repositories"
183
+ ADDED = "installation_repositories.added"
184
+ REMOVED = "installation_repositories.removed"
185
+
186
+
162
187
  class Star(EventType):
163
188
  """`star` events."""
164
189
 
@@ -0,0 +1,154 @@
1
+ """Typed views of GitHub payloads, for autocomplete inside handlers.
2
+
3
+ `event.payload` is `dict[str, Any]` — correct, since it carries GitHub's JSON
4
+ verbatim, but blind to write against. These TypedDicts spell the fields GitHub
5
+ actually sends, so `payloads.pull_request(event)` gives editors and agents
6
+ something to complete against:
7
+
8
+ ```python
9
+ from flyteplugins.github import payloads
10
+
11
+ @app_env.on_event(events.PullRequest.OPENED)
12
+ async def on_primary(event):
13
+ payload = payloads.pull_request(event)
14
+ branch = payload["pull_request"]["head"]["ref"]
15
+ repo = payload["repository"]["full_name"]
16
+ ```
17
+
18
+ The helpers are casts, not validators: the dict is returned untouched, and a
19
+ field GitHub did not send is still a `KeyError` at runtime. Every class is
20
+ `total=False` because GitHub's payloads vary by action and App permissions —
21
+ treat presence the way you already would with a raw dict. Fields beyond these
22
+ still exist in the dict; the types name the commonly-read ones, not the whole
23
+ wire format.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import TYPE_CHECKING, Any, TypedDict, cast
29
+
30
+ if TYPE_CHECKING:
31
+ from flyte.extras.webhooks import WebhookEvent
32
+
33
+ __all__ = [
34
+ "Comment",
35
+ "GitRef",
36
+ "Issue",
37
+ "IssueCommentEvent",
38
+ "Label",
39
+ "PullRequest",
40
+ "PullRequestEvent",
41
+ "Repository",
42
+ "User",
43
+ "issue_comment",
44
+ "pull_request",
45
+ ]
46
+
47
+
48
+ class User(TypedDict, total=False):
49
+ """An account — sender, author, assignee, owner."""
50
+
51
+ login: str
52
+ id: int
53
+ type: str
54
+ html_url: str
55
+
56
+
57
+ class Label(TypedDict, total=False):
58
+ name: str
59
+ color: str
60
+ description: str
61
+
62
+
63
+ class Repository(TypedDict, total=False):
64
+ full_name: str
65
+ name: str
66
+ html_url: str
67
+ clone_url: str
68
+ default_branch: str
69
+ private: bool
70
+ owner: User
71
+
72
+
73
+ class GitRef(TypedDict, total=False):
74
+ """One side of a pull request — `head` or `base`."""
75
+
76
+ ref: str
77
+ sha: str
78
+ label: str
79
+ repo: Repository
80
+
81
+
82
+ class PullRequest(TypedDict, total=False):
83
+ """`payload["pull_request"]` — the PR the event is about."""
84
+
85
+ number: int
86
+ title: str
87
+ body: str
88
+ state: str
89
+ draft: bool
90
+ merged: bool
91
+ html_url: str
92
+ user: User
93
+ labels: list[Label]
94
+ head: GitRef
95
+ base: GitRef
96
+ additions: int
97
+ deletions: int
98
+ changed_files: int
99
+
100
+
101
+ class Issue(TypedDict, total=False):
102
+ """`payload["issue"]` — present on `issues` and `issue_comment` events.
103
+
104
+ A comment on a pull request also arrives as `issue_comment`; the issue then
105
+ carries a `pull_request` key, which is how the two are told apart.
106
+ """
107
+
108
+ number: int
109
+ title: str
110
+ body: str
111
+ state: str
112
+ html_url: str
113
+ user: User
114
+ labels: list[Label]
115
+ pull_request: dict[str, Any]
116
+
117
+
118
+ class Comment(TypedDict, total=False):
119
+ id: int
120
+ body: str
121
+ html_url: str
122
+ user: User
123
+
124
+
125
+ class PullRequestEvent(TypedDict, total=False):
126
+ """A `pull_request` delivery."""
127
+
128
+ action: str
129
+ number: int
130
+ pull_request: PullRequest
131
+ repository: Repository
132
+ sender: User
133
+ installation: dict[str, Any]
134
+
135
+
136
+ class IssueCommentEvent(TypedDict, total=False):
137
+ """An `issue_comment` delivery — the comment-triggered-agent shape."""
138
+
139
+ action: str
140
+ issue: Issue
141
+ comment: Comment
142
+ repository: Repository
143
+ sender: User
144
+ installation: dict[str, Any]
145
+
146
+
147
+ def pull_request(event: WebhookEvent) -> PullRequestEvent:
148
+ """Typed view of a `pull_request` delivery's payload. A cast, not validation."""
149
+ return cast("PullRequestEvent", event.payload)
150
+
151
+
152
+ def issue_comment(event: WebhookEvent) -> IssueCommentEvent:
153
+ """Typed view of an `issue_comment` delivery's payload. A cast, not validation."""
154
+ return cast("IssueCommentEvent", event.payload)
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyteplugins-github
3
+ Version: 2.8.0
4
+ Summary: Receive GitHub webhooks in Flyte.
5
+ Author: Flyte Contributors
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: flyte
9
+ Provides-Extra: app
10
+ Requires-Dist: fastapi>=0.115; extra == "app"
11
+ Requires-Dist: uvicorn>=0.30; extra == "app"
12
+ Provides-Extra: review
13
+ Requires-Dist: PyGithub>=2; extra == "review"
14
+ Provides-Extra: auth
15
+ Requires-Dist: PyJWT[crypto]>=2; extra == "auth"
16
+
17
+ # flyteplugins-github
18
+
19
+ Receive GitHub webhooks in Flyte — JSON or form-encoded, GitHub signs both the
20
+ same way — plus human review gates on pull requests and GitHub App
21
+ installation tokens for agents that clone, push, or open PRs.
22
+
23
+ ```bash
24
+ pip install "flyteplugins-github[app]"
25
+ ```
26
+
27
+ ## Using it
28
+
29
+ Hand a `GitHubProvider()` to a `WebhookAppEnvironment` and register handlers with the
30
+ typed constants in `events`:
31
+
32
+ ```python
33
+ import flyte
34
+ from flyte.extras.webhooks import WebhookAppEnvironment, WebhookEvent, run_once
35
+ from flyteplugins.github import GitHubProvider, events
36
+
37
+ # GitHubProvider.default_secret_env is mounted for you.
38
+ app_env = WebhookAppEnvironment(name="github-webhooks", providers=[GitHubProvider()])
39
+
40
+
41
+ @app_env.on_event(events.PullRequest.OPENED)
42
+ async def handle(event: WebhookEvent):
43
+ import flyte.remote as remote
44
+
45
+ task = remote.Task.get(name="my-env.my_task", auto_version="latest")
46
+ result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
47
+ if not result.created:
48
+ return {"skipped": result.run.name, "url": result.run.url}
49
+ return {"run": result.run.name}
50
+
51
+
52
+ flyte.serve(app_env)
53
+ ```
54
+
55
+ Handlers must `await run_once.aio(...)`. The blocking form stalls the
56
+ app's event loop, and GitHub times deliveries out in seconds.
57
+
58
+ One app can serve several products at once — hand it one provider per product.
59
+
60
+ ## Human review gates
61
+
62
+ `review_pr` parks a run on a `flyte.new_condition` carrying the pull request's
63
+ metadata as JSON, waits for a human to answer in the Flyte UI, and returns a
64
+ typed decision the workflow branches on:
65
+
66
+ ```python
67
+ from flyteplugins.github import review_pr
68
+
69
+
70
+ @env.task
71
+ async def gated_merge(repo: str, number: int) -> str:
72
+ decision = await review_pr(repo, number)
73
+ if not decision.is_approved:
74
+ return f"blocked: {decision.summary}"
75
+ ... # merge, with PyGithub
76
+ return "merged"
77
+ ```
78
+
79
+ The reviewer answers in markdown; `parse_review_payload` accepts raw JSON, a
80
+ fenced block, or JSON buried in prose, and normalizes verdict synonyms
81
+ (`lgtm`, `approved`, `changes_requested`, ...) — because people paste all of
82
+ those.
83
+
84
+ This lives in the plugin because the condition is the part only Flyte can do.
85
+ Reading the pull request is `PyGithub`'s job, which the gate calls directly
86
+ rather than wrapping:
87
+
88
+ ```bash
89
+ pip install "flyteplugins-github[review]"
90
+ ```
91
+
92
+ ## Try it
93
+
94
+ `examples/github_webhooks.py` runs two ways. The first needs no GitHub account:
95
+
96
+ ```bash
97
+ python examples/github_webhooks.py --local # replay a real sample delivery in-process
98
+ python examples/github_webhooks.py # deploy the receiver to Flyte
99
+ ```
100
+
101
+ `--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
102
+ test client, so you see a delivery verified, normalized, and dispatched — plus
103
+ an unsigned one refused with a 401, the same delivery replayed to show the
104
+ dedupe key is stable, and the same delivery form-encoded (GitHub's default
105
+ content type) landing on that same key.
106
+
107
+ ## Setup
108
+
109
+ 1. Invent a shared secret — nothing generates it for you — and store it under
110
+ the name the provider mounts, `GITHUB_WEBHOOK_SECRET`:
111
+ ```bash
112
+ openssl rand -hex 32
113
+ flyte create secret GITHUB_WEBHOOK_SECRET --value <secret>
114
+ ```
115
+ 2. Point GitHub at `<app-url>/webhook/github`, from
116
+ repository Settings → Webhooks → Add webhook, pasting that same string into
117
+ the webhook's **Secret** field. (A GitHub App has its own *Webhook* section
118
+ with the same two fields; either route reaches the same endpoint.) A
119
+ mismatch reads as a 401 in *Recent Deliveries*. Either content type works —
120
+ the form's default `application/x-www-form-urlencoded` wraps the JSON in a
121
+ `payload=` field and is unwrapped automatically; `application/json` keeps
122
+ the deliveries readable in *Recent Deliveries*.
123
+
124
+ GitHub sends a `ping` when the webhook is created; it is answered automatically, so a green check in *Recent Deliveries* means the app is reachable.
125
+
126
+ **Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`), whichever content type the webhook uses.
127
+
128
+ Comment and review events fold the comment id into `resource_id`, so two comments on one issue are two events rather than a redelivery of the first.
129
+
130
+ ## GitHub App tokens
131
+
132
+ Agents that clone, push, or open PRs authenticate best as a GitHub App: hold
133
+ no personal access token, mint a short-lived installation token per operation.
134
+ Tokens live one hour — plenty for a clone or a `gh pr create`, useless to an
135
+ attacker who exfiltrates one from a log:
136
+
137
+ ```python
138
+ import asyncio
139
+
140
+ from flyteplugins.github import clone_url, mint_installation_token
141
+
142
+
143
+ @env.task
144
+ async def open_fix_pr(repo: str) -> str:
145
+ # One HTTPS round trip; keep it off the event loop.
146
+ token = await asyncio.to_thread(mint_installation_token)
147
+ url = clone_url(repo, token) # https://x-access-token:<token>@github.com/...
148
+ ...
149
+ ```
150
+
151
+ ```bash
152
+ pip install "flyteplugins-github[auth]"
153
+ ```
154
+
155
+ Configuration comes from three environment variables — `GITHUB_APP_ID`,
156
+ `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY`. What follows is
157
+ where those values come from, how to store them, how to get them onto a task,
158
+ and what happens when they are absent.
159
+
160
+ ### Where the three values come from
161
+
162
+ Create the app first, from **Settings → Developer settings → GitHub Apps → New
163
+ GitHub App** ([github.com/settings/apps/new](https://github.com/settings/apps/new);
164
+ for an org, **Organization settings → Developer settings → GitHub Apps**). Give
165
+ it only the permissions the agent uses — *Contents: Read* to clone, *Read and
166
+ write* to push, *Pull requests: Read and write* to open PRs, *Issues: Read and
167
+ write* to comment. Then:
168
+
169
+ | Value | Where |
170
+ | --- | --- |
171
+ | `GITHUB_APP_ID` | The app's **General** tab, under *About* → **App ID**. A short number. |
172
+ | `GITHUB_APP_PRIVATE_KEY` | Same tab, **Private keys** → *Generate a private key*. Downloads a `.pem` file. |
173
+ | `GITHUB_APP_INSTALLATION_ID` | Install the app (**Install App** tab), then read the number off the URL you land on. |
174
+
175
+ Two things that reliably cost an hour:
176
+
177
+ - **The App ID is not the Client ID.** The General tab shows both, and the
178
+ Client ID (`Iv23li...`) sits right next to the App ID. Signing a JWT with the
179
+ Client ID as the issuer fails at GitHub, not locally, so the error surfaces as
180
+ a failed mint rather than a bad value.
181
+ - **The private key is shown once.** GitHub hands over the `.pem` at generation
182
+ and never again; losing it means generating a new one and deleting the old.
183
+ Store it in Flyte before deleting the download.
184
+
185
+ The installation id is the fiddly one — it identifies *the app installed on one
186
+ account*, not the app. After installing, the browser lands on
187
+ `https://github.com/settings/installations/<id>` (or
188
+ `https://github.com/organizations/<org>/settings/installations/<id>`), and the
189
+ trailing number is it. To read it without the browser, ask the API — the app
190
+ JWT is the only credential this needs, so it works before any installation
191
+ token exists:
192
+
193
+ ```python
194
+ import json, time, urllib.request
195
+ import jwt # from flyteplugins-github[auth]
196
+
197
+ app_id, pem = "1234567", open("my-agent.private-key.pem").read()
198
+ now = int(time.time())
199
+ app_jwt = jwt.encode({"iat": now - 60, "exp": now + 600, "iss": app_id}, pem, algorithm="RS256")
200
+ request = urllib.request.Request(
201
+ "https://api.github.com/app/installations",
202
+ headers={"Authorization": f"Bearer {app_jwt}", "Accept": "application/vnd.github+json"},
203
+ )
204
+ for installation in json.load(urllib.request.urlopen(request)):
205
+ print(installation["id"], installation["account"]["login"])
206
+ ```
207
+
208
+ ### Storing them as Flyte secrets
209
+
210
+ ```bash
211
+ flyte create secret github-app-id --value 1234567
212
+ flyte create secret github-app-installation-id --value 87654321
213
+ flyte create secret github-app-private-key --from-file ~/Downloads/my-agent.private-key.pem
214
+ ```
215
+
216
+ Use `--from-file` for the key, not `--value`: a PEM is multi-line, and a shell
217
+ that folds or strips those newlines produces a key that fails to parse at mint
218
+ time. `--from-file` stores the bytes verbatim, which is what the RS256 signer
219
+ needs. GitHub's download is a PKCS#1 PEM (`-----BEGIN RSA PRIVATE KEY-----`) and
220
+ is used as-is; no conversion step.
221
+
222
+ The app id and installation id are identifiers rather than credentials —
223
+ nothing breaks if they leak — but keeping all three in one place means one
224
+ mounting story instead of two.
225
+
226
+ `flyte create secret` writes to the org unless `--project`/`--domain` scope it.
227
+ Scope the secrets the same way as the environment that reads them, or leave all
228
+ of them org-wide; a secret in the wrong project is invisible at run time.
229
+
230
+ ### Mounting them on the environment
231
+
232
+ Name the secrets in kebab-case and the env vars come out right on their own:
233
+ `flyte.Secret` upper-cases the key and turns `-` into `_`, so
234
+ `github-app-private-key` mounts as `GITHUB_APP_PRIVATE_KEY`, which is exactly
235
+ what `mint_installation_token()` reads.
236
+
237
+ ```python
238
+ import flyte
239
+
240
+ env = flyte.TaskEnvironment(
241
+ name="github-agent",
242
+ secrets=[
243
+ flyte.Secret("github-app-id"),
244
+ flyte.Secret("github-app-installation-id"),
245
+ flyte.Secret("github-app-private-key"),
246
+ ],
247
+ image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github[auth]"),
248
+ )
249
+ ```
250
+
251
+ Under any other naming, spell the target out — the env var is the contract, the
252
+ secret name is not:
253
+
254
+ ```python
255
+ flyte.Secret("prod-bot-key", as_env_var="GITHUB_APP_PRIVATE_KEY")
256
+ ```
257
+
258
+ Pass the same `secrets=[...]` to a `WebhookAppEnvironment` when a handler mints
259
+ tokens directly rather than launching a task that does.
260
+
261
+ ### When they are missing
262
+
263
+ `GITHUB_TOKEN`/`GH_TOKEN` are honored as fallbacks, so a deployment can migrate
264
+ one secret at a time; once the app secrets exist the fallback never fires. A
265
+ deployment with none of them gets `None` back — with a logged reason — rather
266
+ than a crash, so unauthenticated paths keep working. That means a missing
267
+ secret shows up as *unauthenticated behavior*, not an exception: if clones of
268
+ private repos 404, check that all three landed.
269
+
270
+ ## Event constants
271
+
272
+ `events` spells every event this plugin can dispatch, as `str` enums grouped by
273
+ event type, so a typo fails at import rather than by silently never matching.
274
+ GitHub grows actions faster than any constant list; for one the constants do
275
+ not cover yet, qualify the bare type with `action=`:
276
+
277
+ ```python
278
+ @app_env.on_event(events.PullRequest.ANY, action="auto_merge_enabled")
279
+ ```
280
+
281
+ (Raw qualified strings like `"pull_request.auto_merge_enabled"` work too.)
282
+
283
+ `event.payload` is GitHub's JSON verbatim, typed as `dict[str, Any]`. For
284
+ autocomplete, take a typed view of it — a cast, not a copy or a validation:
285
+
286
+ ```python
287
+ payload = payloads.pull_request(event) # payload["pull_request"]["head"]["ref"] completes
288
+ payload = payloads.issue_comment(event) # payload["comment"]["body"], payload["issue"]["number"], ...
289
+ ```
290
+
291
+ ## What this plugin does not do
292
+
293
+ Wrap the GitHub API. Use `PyGithub` directly from your tasks — see
294
+ `examples/external_saas_integrations`. This plugin owns the parts every
295
+ GitHub agent otherwise duplicates: authenticating an inbound delivery and
296
+ turning it into a run, gating a run on a human review, and minting the App
297
+ token the outbound side authenticates with.
@@ -0,0 +1,10 @@
1
+ flyteplugins/github/__init__.py,sha256=BRBj3eg-RqK6jGcuhv7VUHQFCMKkbEmXn2IMEZqnSDg,4024
2
+ flyteplugins/github/_app_auth.py,sha256=vleBD3urwJYxxIpQJrx6DP1U0eI5f7n8ql-jz9bNZ44,5988
3
+ flyteplugins/github/_provider.py,sha256=ZJFiYc0NeAiWOmVKhnNyAdv4gBDwGdlDVO0TBVJuy30,5305
4
+ flyteplugins/github/_review.py,sha256=6XwWI-lOZ3DIshIRU8i-FrzepJtvXmaVm_wbFAA8Iuw,11037
5
+ flyteplugins/github/events.py,sha256=uEb8QffVdTWdTYQnfkLMcqFw0_kWK-f0rcHzU61DYKo,5224
6
+ flyteplugins/github/payloads.py,sha256=bt27Vw83rWASuXElRLnMdZOhRwGzQjct_2GRqCr3NhM,3734
7
+ flyteplugins_github-2.8.0.dist-info/METADATA,sha256=44dE_oakRj4__YNkoGEtANCxCWO2w14EGa2gFlGjOns,11709
8
+ flyteplugins_github-2.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ flyteplugins_github-2.8.0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
10
+ flyteplugins_github-2.8.0.dist-info/RECORD,,
@@ -1,127 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: flyteplugins-github
3
- Version: 2.7.2
4
- Summary: Receive GitHub webhooks in Flyte.
5
- Author: Flyte Contributors
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: flyte
9
- Provides-Extra: app
10
- Requires-Dist: fastapi>=0.115; extra == "app"
11
- Requires-Dist: uvicorn>=0.30; extra == "app"
12
- Provides-Extra: review
13
- Requires-Dist: PyGithub>=2; extra == "review"
14
-
15
- # flyteplugins-github
16
-
17
- Receive GitHub webhooks in Flyte.
18
-
19
- ```bash
20
- pip install "flyteplugins-github[app]"
21
- ```
22
-
23
- ## Using it
24
-
25
- Hand a `GitHubProvider()` to a `WebhookAppEnvironment` and register handlers with the
26
- typed constants in `events`:
27
-
28
- ```python
29
- import flyte
30
- from flyte.extras.webhooks import WebhookAppEnvironment, run_once
31
- from flyteplugins.github import GitHubProvider, events
32
-
33
- # GitHubProvider.default_secret_env is mounted for you.
34
- app_env = WebhookAppEnvironment(name="github-webhooks", providers=[GitHubProvider()])
35
-
36
-
37
- @app_env.on_event(events.PullRequest.OPENED)
38
- async def handle(event):
39
- import flyte.remote as remote
40
-
41
- task = remote.Task.get(name="my-env.my_task", auto_version="latest")
42
- result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
43
- if not result.created:
44
- return {"skipped": result.run.name, "url": result.run.url}
45
- return {"run": result.run.name}
46
-
47
-
48
- flyte.serve(app_env)
49
- ```
50
-
51
- Handlers must `await run_once.aio(...)`. The blocking form stalls the
52
- app's event loop, and GitHub times deliveries out in seconds.
53
-
54
- One app can serve several products at once — hand it one provider per product.
55
-
56
- ## Human review gates
57
-
58
- `review_pr` parks a run on a `flyte.new_condition` carrying the pull request's
59
- metadata as JSON, waits for a human to answer in the Flyte UI, and returns a
60
- typed decision the workflow branches on:
61
-
62
- ```python
63
- from flyteplugins.github import review_pr
64
-
65
-
66
- @env.task
67
- async def gated_merge(repo: str, number: int) -> str:
68
- decision = await review_pr(repo, number)
69
- if not decision.is_approved:
70
- return f"blocked: {decision.summary}"
71
- ... # merge, with PyGithub
72
- return "merged"
73
- ```
74
-
75
- The reviewer answers in markdown; `parse_review_payload` accepts raw JSON, a
76
- fenced block, or JSON buried in prose, and normalizes verdict synonyms
77
- (`lgtm`, `approved`, `changes_requested`, ...) — because people paste all of
78
- those.
79
-
80
- This lives in the plugin because the condition is the part only Flyte can do.
81
- Reading the pull request is `PyGithub`'s job, which the gate calls directly
82
- rather than wrapping:
83
-
84
- ```bash
85
- pip install "flyteplugins-github[review]"
86
- ```
87
-
88
- ## Try it
89
-
90
- `examples/github_webhooks.py` runs two ways. The first needs no GitHub account:
91
-
92
- ```bash
93
- python examples/github_webhooks.py --local # replay a real sample delivery in-process
94
- python examples/github_webhooks.py # deploy the receiver to Flyte
95
- ```
96
-
97
- `--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
98
- test client, so you see a delivery verified, normalized, and dispatched — plus
99
- an unsigned one refused with a 401, and the same delivery replayed to show the
100
- dedupe key is stable.
101
-
102
- ## Setup
103
-
104
- 1. Store the secret and mount it on the app:
105
- ```bash
106
- flyte create secret GITHUB_WEBHOOK_SECRET --value <secret>
107
- ```
108
- 2. Point GitHub at `<app-url>/webhook/github`, from
109
- repository Settings → Webhooks → Add webhook, content type `application/json`.
110
-
111
- GitHub sends a `ping` when the webhook is created; it is answered automatically, so a green check in *Recent Deliveries* means the app is reachable.
112
-
113
- **Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`).
114
-
115
- Comment and review events fold the comment id into `resource_id`, so two comments on one issue are two events rather than a redelivery of the first.
116
-
117
- ## Event constants
118
-
119
- `events` spells every event this plugin can dispatch, as `str` enums grouped by
120
- event type, so a typo fails at import rather than by silently never matching.
121
- Raw strings still work, for events the constants do not cover yet.
122
-
123
- ## What this plugin does not do
124
-
125
- Call the GitHub API. Use `PyGithub` directly from your tasks — see
126
- `examples/external_saas_integrations`. This plugin owns only the part that is
127
- Flyte's: authenticating an inbound delivery and turning it into a run.
@@ -1,8 +0,0 @@
1
- flyteplugins/github/__init__.py,sha256=rNlGTRndjurjvDCKmaGqj1hiQGpS9woDlTYmRmGmJDk,3352
2
- flyteplugins/github/_provider.py,sha256=3mK9p-avlwR7G4j2l5FikwzaxRZVyuTR-9lHeGSI5Po,3556
3
- flyteplugins/github/_review.py,sha256=6XwWI-lOZ3DIshIRU8i-FrzepJtvXmaVm_wbFAA8Iuw,11037
4
- flyteplugins/github/events.py,sha256=_SghpvwL_m7RjPzVjMx04G-1OorNAI1275vVyuFSmkY,4358
5
- flyteplugins_github-2.7.2.dist-info/METADATA,sha256=SXWg8J3AYeRacctnaBEJbIbq5eO5oH8hdJdhn6TGztQ,4231
6
- flyteplugins_github-2.7.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
- flyteplugins_github-2.7.2.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
8
- flyteplugins_github-2.7.2.dist-info/RECORD,,