Diagnostic GitHub API
the App was never subscribed to the event it waits for
The handler was written, unit tested against a payload fixture, code reviewed and shipped. In production it has never run. Not once, not slowly, not with an error — the delivery log has thousands of entries and none of them is that event. There is nothing to debug, because nothing happened.
A GitHub App receives only the webhook events it subscribes to, and the subscription is a property of the App itself. GET /app with the App's JWT returns the events array: if the event you are waiting for is not in it, no delivery for that event has ever been created and none ever will be until it is.
The part that catches people is that the subscription is gated by permission. An App can only declare an event whose permission it holds — pull_request needs pull_requests, check_run needs checks — and when the permission is absent the checkbox is not offered at all, so the subscription silently never happens and nothing records the attempt. The repair therefore has three ordered steps: add the permission, subscribe to the event, then get every installation to accept the upgrade. Doing only the last is the common mistake.
This is the App-side twin of the note about a repository webhook that is not subscribed to an event, and the difference is worth being exact about. That one is a hook you created on a repository or an organization, whose events list you read from GET /repos/{owner}/{repo}/hooks and which a repo admin can widen in ten seconds. This one is the subscription declared by the App, read from GET /app, gated by App permissions, and inert until installations consent.
The problem in plain words
The absence of evidence is the whole difficulty. Every other webhook failure leaves a trace — a delivery with a 500, a timeout, a signature rejection — and can be pulled up in the delivery log and stared at. An unsubscribed event produces no delivery, so the log is not empty, it is full of other things, and the thing you are looking for is not there to be looked at. There is no error to search for and no correlation id to follow.
So the search goes to the receiver, and stays there. The routing table gets instrumented, the signature verification gets a debug branch, the ingress logs get grepped for the event name, and every one of those investigations correctly reports that nothing arrived. Hours go into proving a negative about code that was never involved. The strong smell that ought to redirect it — that other events arrive perfectly — reads as reassurance instead of as the clue it is.
The permission gate is what makes it stick. Somebody eventually goes to the App's settings to subscribe, finds the event is not offered, and concludes that GitHub does not support it. The event is real and documented; it is simply hidden behind a permission the App does not hold, and the settings page does not explain the connection. That is a very short step from filing the whole thing as a platform limitation and writing a polling loop instead.
Why it happens
The App's subscription list is a single object. One App, one events array, one webhook URL. Unlike repository hooks there is no per-account variation to chase: if the array does not contain the event, no installation of the App receives it, anywhere. That makes the check cheap and the answer total.
Permissions gate which events can be declared. Each event is tied to a permission that governs the resource it describes, and the App must hold that permission before the event can be selected. This is why so many reports of a missing event turn into a report that the event does not exist: the interface omits what you are not entitled to, without saying why.
Subscribing is a permission change, so it needs acceptance. Adding the gating permission edits the App, which puts every existing installation into the pending state described in the note on unaccepted permission upgrades. The event will start arriving from accounts that accept and not from the others, which produces a second, later, partial-looking failure if you were not expecting it.
The delivery log is corroboration, not proof. GET /app/hook/deliveries shows the events that actually arrived within the retention window. An event absent from it is consistent with not being subscribed and also with simply not having happened — nobody opened a pull request this week — so it can confirm a finding the events array already made and it cannot make one on its own.
Names are exact and the mapping is curated. GitHub spells events with underscores, and there is no endpoint that returns the event-to-permission mapping, so the table in the script is written from the documentation rather than fetched. An event it has never heard of gets reported as unknown, with the subscription answer given plainly and the permission answer withheld, which is more useful than a confident guess about a gate.
The fix, as a flow
Two GETs, one of which is evidence rather than diagnosis. The App record names the events it subscribes to and the permissions it holds, and those two facts are related: an event can only be declared once the permission gating it is held. The delivery log confirms from the other side, by showing which events have actually arrived. The repair has three steps in a fixed order, and the script prints all three because doing only the last one is the usual mistake.
How to fix it
Write down the events your handlers actually implement
Not the ones in the design document. Grep the receiver for the event names it switches on, and use exactly those spellings. This list is the input to the whole check, and the most common way the check comes back clean on a broken system is that the list was written from memory.
Read the App's declaration
GET /app with the App's JWT returns events and permissions together, which is convenient because you need both. Anything in your handler list and not in events has never been delivered. There is no per-installation nuance at this step: the App subscribes or it does not.
Check whether the gating permission is even held
For each unsubscribed event, look up the permission that gates it and check the App's map. Absent means you cannot simply tick a box — the box will not be there — and the first step is the permission. Present means the subscription is a one-line change to the App, followed by the acceptance round.
Corroborate against what has actually arrived
GET /app/hook/deliveries lists recent deliveries with an event field each. Collect the distinct values. A subscribed event that has never arrived is usually quiet rather than broken, and saying so stops the next person from unsubscribing something that works.
Do the three steps in order
Add the permission, subscribe to the event, then get each installation's owner to accept. Skipping the first leaves the checkbox missing; skipping the last leaves the event arriving from some accounts and not others, which looks like a completely different bug and gets investigated as one.
How to check it worked
After the subscription is added and accepted, the same read moves the event from not-subscribed to subscribed, and the delivery log starts carrying it the next time one happens.
GITHUB_APP_JWT=$(python3 sign_app_jwt.py) python3 github_app_event_subscriptions.py \
--handles push,pull_request,pull_request_review_thread,release
# app subscribes to 2 event(s), holds 3 permission(s)
# handlers-unreachable: 2 of 4 handled event(s) can never fire
# pull_request_review_thread: not subscribed, and the pull_requests
# permission that gates it is not held
# release: not subscribed, but the contents permission that gates it is held
# repair, in order: add pull_requests, subscribe, then have every
# installation accept the upgrade
# after all three steps
# all-subscribed: every handled event is declared by the App
The full code
Two GETs and a curated table. The subscription half is a set membership test and is exact; the permission half is a lookup in a mapping written from the documentation, because nothing in the API returns which permission gates which event. That asymmetry is reflected in the output: an event the table does not know still gets a truthful subscription answer, and gets unknown rather than a guess for the gate. The normalisation is deliberately narrow — case and surrounding space only — so that a genuinely misspelled event name stays visible instead of being silently corrected.
"""Find webhook events a GitHub App's handlers wait for and never receive.
Read only. Two GETs with the App's JWT: the App's own record and its recent
webhook deliveries. Nothing is subscribed, permitted or changed. Subscribing is
an edit to the App and then a human acceptance on every installation, so the
script prints the three steps in the order they have to happen.
A GitHub App receives only the events it declares, and it can only declare an
event whose gating permission it holds. When the permission is absent the
subscription checkbox is not offered at all, so the subscription silently never
happens and nothing anywhere records the attempt.
This is the App-side case. A repository or organization webhook has its own
events list, read from the repository hooks endpoint, which is a different
object with a different repair.
Environment:
GITHUB_APP_JWT the JWT your own signing code produced
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_app_event_subscriptions")
API = "https://api.github.com"
UA = "github-app-event-subscriptions/1.0"
# Which App permission gates which event. Nothing in the API returns this, so
# the table is written from the documentation rather than fetched, and an event
# that is not in it is reported as unknown rather than guessed at. A wrong
# answer here sends somebody to request a permission they do not need.
EVENT_PERMISSION = {
"check_run": "checks",
"check_suite": "checks",
"commit_comment": "contents",
"create": "contents",
"delete": "contents",
"deployment": "deployments",
"deployment_status": "deployments",
"fork": "metadata",
"issue_comment": "issues",
"issues": "issues",
"label": "metadata",
"member": "members",
"membership": "members",
"milestone": "issues",
"organization": "members",
"public": "metadata",
"pull_request": "pull_requests",
"pull_request_review": "pull_requests",
"pull_request_review_comment": "pull_requests",
"pull_request_review_thread": "pull_requests",
"push": "contents",
"release": "contents",
"repository": "metadata",
"repository_dispatch": "contents",
"star": "metadata",
"status": "statuses",
"team_add": "members",
"watch": "metadata",
"workflow_dispatch": "actions",
"workflow_job": "actions",
"workflow_run": "actions",
}
# Every event carries metadata implicitly, so an App that holds nothing still
# holds this one. Listing it keeps the "not permitted" branch honest.
ALWAYS_HELD = ("metadata",)
def normalize(event):
"""An event name reduced to the form GitHub spells it in. Pure.
Case and surrounding whitespace only. A genuinely misspelled name is left
misspelled so it stays visible as unknown, because quietly correcting it
would hide the actual mistake in a report about missing events.
"""
return str(event or "").strip().lower()
def gating_permission(event):
"""The App permission that gates an event, or None if unknown. Pure."""
return EVENT_PERMISSION.get(normalize(event))
def holds(permissions, name):
"""Whether the App holds a permission at read or better. Pure."""
if name in ALWAYS_HELD:
return True
value = (permissions or {}).get(name)
return bool(value) and str(value).strip().lower() != "none"
def seen_events(deliveries):
"""Distinct event names in a delivery log page. Pure."""
out = set()
for row in deliveries or []:
if isinstance(row, dict) and row.get("event"):
out.add(normalize(row["event"]))
return out
def subscription_state(event, subscribed, permissions, seen=None):
"""Sort one handled event into a state. Pure.
subscribed is the App's events array, permissions its permission map, seen
the distinct events observed in the delivery log. seen only ever refines a
positive answer: an event that has not arrived may simply not have
happened, so its absence is never a finding on its own.
"""
name = normalize(event)
declared = {normalize(e) for e in (subscribed or [])}
gate = gating_permission(name)
if name in declared:
if seen is not None and name in seen:
return ("subscribed-and-arriving",
"%s is declared by the App and has arrived recently." % name)
return ("subscribed-not-yet-seen",
"%s is declared by the App but has not arrived in the "
"retention window, which usually means it has not happened "
"rather than that it is broken." % name)
if gate is None:
return ("not-subscribed-gate-unknown",
"%s is not declared by the App, so it has never been "
"delivered. This script does not know which permission gates "
"it; check the published event list before requesting one."
% name)
if not holds(permissions, gate):
return ("not-subscribed-blocked",
"%s is not declared, and the %s permission that gates it is "
"not held. The subscription cannot be ticked until the "
"permission is added." % (name, gate))
return ("not-subscribed-permitted",
"%s is not declared, but the %s permission that gates it is held, "
"so subscribing is an edit to the App followed by an acceptance "
"round." % (name, gate))
def rows(handled, subscribed, permissions, seen=None):
"""One row per handled event, in the order they were given. Pure."""
out = []
for event in handled or []:
state, detail = subscription_state(event, subscribed, permissions, seen)
out.append({"event": normalize(event), "state": state, "detail": detail,
"gated_by": gating_permission(event)})
return out
def verdict(report):
"""Turn the rows into one finding. Pure."""
report = report or []
if not report:
return ("nothing-handled",
"no handled events were supplied, so there is nothing to "
"compare the App's subscriptions against.")
unreachable = [r for r in report if r["state"].startswith("not-subscribed")]
if unreachable:
return ("handlers-unreachable",
"%d of %d handled event(s) can never fire, because the App "
"does not declare them." % (len(unreachable), len(report)))
quiet = [r for r in report if r["state"] == "subscribed-not-yet-seen"]
if quiet:
return ("all-subscribed-some-quiet",
"every handled event is declared. %d of them has not arrived "
"in the retention window, which is not by itself a fault."
% len(quiet))
return ("all-subscribed",
"every handled event is declared by the App and arriving.")
def repair_steps(report):
"""The ordered repair, as lines. Pure.
Order matters and is the point of the note: add the permission, subscribe,
then have installations accept. Doing only the last is the usual mistake.
"""
blocked = sorted({r["gated_by"] for r in report or []
if r["state"] == "not-subscribed-blocked" and r["gated_by"]})
missing = sorted({r["event"] for r in report or []
if r["state"].startswith("not-subscribed")})
if not missing:
return []
steps = []
if blocked:
steps.append("add the %s permission to the App; until then the "
"subscription cannot be selected at all"
% ", ".join(blocked))
steps.append("subscribe the App to %s" % ", ".join(missing))
steps.append("have an owner on every installation accept the resulting "
"permission request, or the event will arrive from some "
"accounts and not others")
return steps
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
url = API + path if path.startswith("/") else path
r = session.get(url, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--handles", default="",
help="comma-separated event names your handlers implement, "
"spelled the way GitHub spells them")
args = ap.parse_args()
jwt = os.environ.get("GITHUB_APP_JWT")
if not jwt:
log.error("set GITHUB_APP_JWT to the JWT your own signing code "
"produced. The App's events array is on the App record, "
"which an installation token cannot read")
return 2
handled = [h for h in (p.strip() for p in args.handles.split(",")) if h]
if not handled:
log.error("pass --handles with the event names your receiver "
"implements; without them there is nothing to compare")
return 2
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + jwt,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
status, app = get(session, "/app")
if status != 200 or not isinstance(app, dict):
log.error("GET /app returned %d, so the App's subscriptions cannot be "
"read", status)
return 2
subscribed = app.get("events") or []
permissions = app.get("permissions") or {}
log.info("app subscribes to %d event(s), holds %d permission(s)",
len(subscribed), len(permissions))
seen = None
d_status, deliveries = get(session, "/app/hook/deliveries?per_page=100")
if d_status == 200 and isinstance(deliveries, list):
seen = seen_events(deliveries)
log.info("delivery log shows %d distinct event(s) in the retention "
"window", len(seen))
else:
log.info("delivery log unavailable (%d); the subscription answer does "
"not depend on it", d_status)
report = rows(handled, subscribed, permissions, seen)
state, detail = verdict(report)
log.info("%s: %s", state, detail)
for row in report:
if row["state"] != "subscribed-and-arriving":
log.info(" %s", row["detail"])
for i, line in enumerate(repair_steps(report), start=1):
log.info("repair step %d: %s", i, line)
print(json.dumps({"subscribed": sorted(normalize(e) for e in subscribed),
"permissions": permissions,
"seen_in_deliveries": sorted(seen) if seen is not None else None,
"state": state, "events": report}, indent=2, default=str))
return 1 if state == "handlers-unreachable" else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find webhook events a GitHub App's handlers wait for and never receive.
*
* Read only. Two GETs with the App's JWT: the App's own record and its recent
* webhook deliveries. Subscribing is an edit to the App and then a human
* acceptance on every installation, so the script prints the three steps in
* the order they have to happen.
*
* This is the App-side case. A repository or organization webhook has its own
* events list, which is a different object with a different repair.
*
* Environment:
* GITHUB_APP_JWT the JWT your own signing code produced
*/
const API = 'https://api.github.com';
const UA = 'github-app-event-subscriptions/1.0';
/** Which App permission gates which event. Curated, not fetched. */
export const EVENT_PERMISSION = {
check_run: 'checks',
check_suite: 'checks',
commit_comment: 'contents',
create: 'contents',
delete: 'contents',
deployment: 'deployments',
deployment_status: 'deployments',
fork: 'metadata',
issue_comment: 'issues',
issues: 'issues',
label: 'metadata',
member: 'members',
membership: 'members',
milestone: 'issues',
organization: 'members',
public: 'metadata',
pull_request: 'pull_requests',
pull_request_review: 'pull_requests',
pull_request_review_comment: 'pull_requests',
pull_request_review_thread: 'pull_requests',
push: 'contents',
release: 'contents',
repository: 'metadata',
repository_dispatch: 'contents',
star: 'metadata',
status: 'statuses',
team_add: 'members',
watch: 'metadata',
workflow_dispatch: 'actions',
workflow_job: 'actions',
workflow_run: 'actions',
};
/** Permissions every App holds implicitly. */
export const ALWAYS_HELD = ['metadata'];
/** An event name reduced to the form GitHub spells it in. Pure. */
export function normalize(event) {
return String(event ?? '').trim().toLowerCase();
}
/** The App permission that gates an event, or null if unknown. Pure. */
export function gatingPermission(event) {
return EVENT_PERMISSION[normalize(event)] ?? null;
}
/** Whether the App holds a permission at read or better. Pure. */
export function holds(permissions, name) {
if (ALWAYS_HELD.includes(name)) return true;
const value = (permissions ?? {})[name];
return Boolean(value) && String(value).trim().toLowerCase() !== 'none';
}
/** Distinct event names in a delivery log page. Pure. */
export function seenEvents(deliveries) {
const out = new Set();
for (const row of deliveries ?? []) {
if (row && typeof row === 'object' && row.event) out.add(normalize(row.event));
}
return out;
}
/** Sort one handled event into a state. Pure. */
export function subscriptionState(event, subscribed, permissions, seen = null) {
const name = normalize(event);
const declared = new Set((subscribed ?? []).map(normalize));
const gate = gatingPermission(name);
if (declared.has(name)) {
if (seen && seen.has(name)) {
return ['subscribed-and-arriving',
`${name} is declared by the App and has arrived recently.`];
}
return ['subscribed-not-yet-seen',
`${name} is declared by the App but has not arrived in the retention ` +
'window, which usually means it has not happened rather than that it ' +
'is broken.'];
}
if (gate === null) {
return ['not-subscribed-gate-unknown',
`${name} is not declared by the App, so it has never been delivered. ` +
'This script does not know which permission gates it; check the ' +
'published event list before requesting one.'];
}
if (!holds(permissions, gate)) {
return ['not-subscribed-blocked',
`${name} is not declared, and the ${gate} permission that gates it is ` +
'not held. The subscription cannot be ticked until the permission is added.'];
}
return ['not-subscribed-permitted',
`${name} is not declared, but the ${gate} permission that gates it is ` +
'held, so subscribing is an edit to the App followed by an acceptance round.'];
}
/** One row per handled event, in the order they were given. Pure. */
export function rows(handled, subscribed, permissions, seen = null) {
return (handled ?? []).map((event) => {
const [state, detail] = subscriptionState(event, subscribed, permissions, seen);
return { event: normalize(event), state, detail, gated_by: gatingPermission(event) };
});
}
/** Turn the rows into one finding. Pure. */
export function verdict(report) {
const all = report ?? [];
if (!all.length) {
return ['nothing-handled',
'no handled events were supplied, so there is nothing to compare the ' +
"App's subscriptions against."];
}
const unreachable = all.filter((r) => r.state.startsWith('not-subscribed'));
if (unreachable.length) {
return ['handlers-unreachable',
`${unreachable.length} of ${all.length} handled event(s) can never ` +
'fire, because the App does not declare them.'];
}
const quiet = all.filter((r) => r.state === 'subscribed-not-yet-seen');
if (quiet.length) {
return ['all-subscribed-some-quiet',
`every handled event is declared. ${quiet.length} of them has not ` +
'arrived in the retention window, which is not by itself a fault.'];
}
return ['all-subscribed',
'every handled event is declared by the App and arriving.'];
}
/** The ordered repair, as lines. Pure. */
export function repairSteps(report) {
const all = report ?? [];
const blocked = [...new Set(all
.filter((r) => r.state === 'not-subscribed-blocked' && r.gated_by)
.map((r) => r.gated_by))].sort();
const missing = [...new Set(all
.filter((r) => r.state.startsWith('not-subscribed'))
.map((r) => r.event))].sort();
if (!missing.length) return [];
const steps = [];
if (blocked.length) {
steps.push(`add the ${blocked.join(', ')} permission to the App; until ` +
'then the subscription cannot be selected at all');
}
steps.push(`subscribe the App to ${missing.join(', ')}`);
steps.push('have an owner on every installation accept the resulting ' +
'permission request, or the event will arrive from some accounts and not others');
return steps;
}
async function get(jwt, path) {
const url = path.startsWith('/') ? API + path : path;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const jwt = process.env.GITHUB_APP_JWT;
if (!jwt) {
console.error('set GITHUB_APP_JWT to the JWT your own signing code ' +
"produced. The App's events array is on the App record, which an " +
'installation token cannot read');
process.exitCode = 2;
return;
}
const handled = (process.argv[2] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
if (!handled.length) {
console.error('pass the event names your receiver implements as the first ' +
'argument, comma separated; without them there is nothing to compare');
process.exitCode = 2;
return;
}
const app = await get(jwt, '/app');
if (app.status !== 200 || !app.body || typeof app.body !== 'object') {
console.error(`GET /app returned ${app.status}, so the App's ` +
'subscriptions cannot be read');
process.exitCode = 2;
return;
}
const subscribed = app.body.events ?? [];
const permissions = app.body.permissions ?? {};
console.log(`app subscribes to ${subscribed.length} event(s), holds ` +
`${Object.keys(permissions).length} permission(s)`);
let seen = null;
const log = await get(jwt, '/app/hook/deliveries?per_page=100');
if (log.status === 200 && Array.isArray(log.body)) {
seen = seenEvents(log.body);
console.log(`delivery log shows ${seen.size} distinct event(s) in the ` +
'retention window');
} else {
console.log(`delivery log unavailable (${log.status}); the subscription ` +
'answer does not depend on it');
}
const report = rows(handled, subscribed, permissions, seen);
const [state, detail] = verdict(report);
console.log(`${state}: ${detail}`);
for (const row of report) {
if (row.state !== 'subscribed-and-arriving') console.log(` ${row.detail}`);
}
repairSteps(report).forEach((line, i) => {
console.log(`repair step ${i + 1}: ${line}`);
});
console.log(JSON.stringify({
subscribed: subscribed.map(normalize).sort(),
permissions,
seen_in_deliveries: seen ? [...seen].sort() : null,
state,
events: report,
}, null, 2));
process.exitCode = state === 'handlers-unreachable' ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main(), fail on the missing JWT and set an exit code that
// fails the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Three distinctions carry the whole script and each gets tests. An unsubscribed event whose permission is missing is not the same finding as one whose permission is held, because only the first has a step before the checkbox. A subscribed event that has not arrived is not a fault, so the delivery log must never be allowed to turn silence into a finding. And an event the table has never heard of must still get a truthful subscription answer while withholding the permission one.
from github_app_event_subscriptions import (
gating_permission, holds, normalize, repair_steps, rows, seen_events,
subscription_state, verdict,
)
SUBSCRIBED = ["push", "issues"]
PERMISSIONS = {"contents": "read", "issues": "write", "metadata": "read"}
def test_names_are_normalised_for_case_and_space_only():
assert normalize(" Pull_Request ") == "pull_request"
assert normalize("pull-request") == "pull-request"
def test_a_misspelled_event_stays_unknown_rather_than_being_corrected():
assert gating_permission("pull-request") is None
assert gating_permission("pull_request") == "pull_requests"
def test_metadata_counts_as_held_even_when_absent_from_the_map():
assert holds({}, "metadata")
assert not holds({}, "checks")
assert not holds({"checks": "none"}, "checks")
assert holds({"checks": "read"}, "checks")
def test_an_unsubscribed_event_without_its_permission_is_blocked():
state, detail = subscription_state("pull_request_review_thread", SUBSCRIBED,
PERMISSIONS)
assert state == "not-subscribed-blocked"
assert "pull_requests permission" in detail
assert "cannot be ticked" in detail
def test_an_unsubscribed_event_whose_permission_is_held_is_a_lighter_repair():
state, detail = subscription_state("release", SUBSCRIBED, PERMISSIONS)
assert state == "not-subscribed-permitted"
assert "contents permission" in detail
def test_an_unknown_event_gets_a_subscription_answer_and_no_permission_guess():
state, detail = subscription_state("sponsorship_tier_change", SUBSCRIBED,
PERMISSIONS)
assert state == "not-subscribed-gate-unknown"
assert "does not know which permission" in detail
def test_a_subscribed_event_seen_in_the_log_is_healthy():
seen = seen_events([{"event": "push"}, {"event": "Push"}, {"nope": 1}])
assert seen == {"push"}
assert subscription_state("push", SUBSCRIBED, PERMISSIONS, seen)[0] == \
"subscribed-and-arriving"
def test_silence_in_the_delivery_log_is_never_a_finding_on_its_own():
state, detail = subscription_state("issues", SUBSCRIBED, PERMISSIONS, set())
assert state == "subscribed-not-yet-seen"
assert "rather than that it is broken" in detail
def test_any_unsubscribed_handler_makes_the_whole_report_unreachable():
report = rows(["push", "release"], SUBSCRIBED, PERMISSIONS, {"push"})
state, detail = verdict(report)
assert state == "handlers-unreachable"
assert "1 of 2" in detail
def test_a_fully_subscribed_quiet_app_is_not_reported_as_broken():
report = rows(["push", "issues"], SUBSCRIBED, PERMISSIONS, {"push"})
assert verdict(report)[0] == "all-subscribed-some-quiet"
def test_a_fully_subscribed_busy_app_is_clean():
report = rows(["push", "issues"], SUBSCRIBED, PERMISSIONS, {"push", "issues"})
assert verdict(report)[0] == "all-subscribed"
def test_no_handled_events_is_not_a_pass():
assert verdict([])[0] == "nothing-handled"
def test_the_repair_puts_the_permission_before_the_subscription():
report = rows(["pull_request_review_thread"], SUBSCRIBED, PERMISSIONS)
steps = repair_steps(report)
assert len(steps) == 3
assert "add the pull_requests permission" in steps[0]
assert "subscribe the App to pull_request_review_thread" in steps[1]
assert "accept" in steps[2]
def test_the_permission_step_is_skipped_when_the_permission_is_already_held():
steps = repair_steps(rows(["release"], SUBSCRIBED, PERMISSIONS))
assert len(steps) == 2
assert steps[0].startswith("subscribe the App to release")
def test_a_clean_report_has_no_repair():
assert repair_steps(rows(["push"], SUBSCRIBED, PERMISSIONS, {"push"})) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
gatingPermission, holds, normalize, repairSteps, rows, seenEvents,
subscriptionState, verdict,
} from './github-app-event-subscriptions.mjs';
const SUBSCRIBED = ['push', 'issues'];
const PERMISSIONS = { contents: 'read', issues: 'write', metadata: 'read' };
test('names are normalised for case and space only', () => {
assert.equal(normalize(' Pull_Request '), 'pull_request');
assert.equal(normalize('pull-request'), 'pull-request');
});
test('a misspelled event stays unknown rather than being corrected', () => {
assert.equal(gatingPermission('pull-request'), null);
assert.equal(gatingPermission('pull_request'), 'pull_requests');
});
test('metadata counts as held even when absent from the map', () => {
assert.ok(holds({}, 'metadata'));
assert.ok(!holds({}, 'checks'));
assert.ok(!holds({ checks: 'none' }, 'checks'));
assert.ok(holds({ checks: 'read' }, 'checks'));
});
test('an unsubscribed event without its permission is blocked', () => {
const [state, detail] = subscriptionState('pull_request_review_thread', SUBSCRIBED, PERMISSIONS);
assert.equal(state, 'not-subscribed-blocked');
assert.match(detail, /pull_requests permission/);
assert.match(detail, /cannot be ticked/);
});
test('an unsubscribed event whose permission is held is a lighter repair', () => {
const [state, detail] = subscriptionState('release', SUBSCRIBED, PERMISSIONS);
assert.equal(state, 'not-subscribed-permitted');
assert.match(detail, /contents permission/);
});
test('an unknown event gets a subscription answer and no permission guess', () => {
const [state, detail] = subscriptionState('sponsorship_tier_change', SUBSCRIBED, PERMISSIONS);
assert.equal(state, 'not-subscribed-gate-unknown');
assert.match(detail, /does not know which permission/);
});
test('a subscribed event seen in the log is healthy', () => {
const seen = seenEvents([{ event: 'push' }, { event: 'Push' }, { nope: 1 }]);
assert.deepEqual([...seen], ['push']);
assert.equal(subscriptionState('push', SUBSCRIBED, PERMISSIONS, seen)[0],
'subscribed-and-arriving');
});
test('silence in the delivery log is never a finding on its own', () => {
const [state, detail] = subscriptionState('issues', SUBSCRIBED, PERMISSIONS, new Set());
assert.equal(state, 'subscribed-not-yet-seen');
assert.match(detail, /rather than that it is broken/);
});
test('any unsubscribed handler makes the whole report unreachable', () => {
const report = rows(['push', 'release'], SUBSCRIBED, PERMISSIONS, new Set(['push']));
const [state, detail] = verdict(report);
assert.equal(state, 'handlers-unreachable');
assert.match(detail, /1 of 2/);
});
test('a fully subscribed quiet app is not reported as broken', () => {
const report = rows(['push', 'issues'], SUBSCRIBED, PERMISSIONS, new Set(['push']));
assert.equal(verdict(report)[0], 'all-subscribed-some-quiet');
});
test('a fully subscribed busy app is clean', () => {
const report = rows(['push', 'issues'], SUBSCRIBED, PERMISSIONS, new Set(['push', 'issues']));
assert.equal(verdict(report)[0], 'all-subscribed');
});
test('no handled events is not a pass', () => {
assert.equal(verdict([])[0], 'nothing-handled');
});
test('the repair puts the permission before the subscription', () => {
const steps = repairSteps(rows(['pull_request_review_thread'], SUBSCRIBED, PERMISSIONS));
assert.equal(steps.length, 3);
assert.match(steps[0], /add the pull_requests permission/);
assert.match(steps[1], /subscribe the App to pull_request_review_thread/);
assert.match(steps[2], /accept/);
});
test('the permission step is skipped when the permission is already held', () => {
const steps = repairSteps(rows(['release'], SUBSCRIBED, PERMISSIONS));
assert.equal(steps.length, 2);
assert.ok(steps[0].startsWith('subscribe the App to release'));
});
test('a clean report has no repair', () => {
assert.deepEqual(repairSteps(rows(['push'], SUBSCRIBED, PERMISSIONS, new Set(['push']))), []);
});
FAQ
How is this different from a repository webhook that is not subscribed?
They are two different objects with two different repairs. A repository or organization webhook is something you created on a repository; its events list is read from the repository hooks endpoint, a repo admin can widen it in seconds, and nothing about it is gated by App permissions. This note is about the subscription declared by the App itself, read from GET /app, which applies to every installation at once, cannot include an event whose permission the App lacks, and does not take effect on existing installations until their owners accept. If you are not using a GitHub App at all, you want the repository hook note instead.
Why is the event missing from the subscription list in the App settings?
Because the App does not hold the permission that gates it, and the interface omits what you are not entitled to rather than showing it disabled with an explanation. This is the single most common reason people conclude that GitHub does not support an event they can plainly see documented. Add the gating permission first and the checkbox appears. The script names the permission for you precisely so this step does not turn into a search.
The delivery log has no entries for the event. Is that the proof?
No, and treating it as proof is how a working subscription gets removed. An event absent from the delivery log is equally consistent with nobody having triggered it in the retention window, which for a quiet repository is entirely normal. The authoritative answer is the events array on the App record, which says what will ever be delivered rather than what happened to be delivered lately. The log is useful for the opposite direction: an event that has arrived is definitely subscribed.
I subscribed to the event and it still does not arrive for some accounts.
That is the acceptance step, and it is a separate note. Adding the gating permission changed the App's permissions, so every installation that existed before the change is still running on the grant it accepted, and the new subscription is inert for those accounts until an owner accepts. The symptom is characteristic: the event arrives from newly installed accounts and from accounts that acted on the email, and not from anybody else, which looks random until you list the installations.
Can the script subscribe the App for me?
No. Editing an App's events and permissions is a write, and this section's scripts never write. It also would not finish the job: the acceptance round is a human act performed by an account owner on every existing installation, and no API call you make can substitute for it. What the script does is print the three steps in the order they have to happen, with the permission named, which is the part people get wrong when they work from the settings page alone.
Related field notes
- A repository hook not subscribed to an event
- A permission upgrade installations never accepted
- Webhook deliveries that have been failing unnoticed
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Webhook events and payloads — GitHub Docs
- Using webhooks with GitHub Apps — GitHub Docs
- Apps — GitHub REST API
- Permissions required for GitHub Apps — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.