github-webhooks / references/payload-cookbook.md

GitHub webhook payload cookbook

A reference shipped with the skill · 957 words

GitHub webhook payload cookbook

Worked predicates. Each recipe states the event, the condition, and the exact fields the condition reads, so a reviewer can check it against the event reference without running anything.

Field names follow the JSON payload. Examples are JavaScript for concreteness; the logic ports unchanged.

0. The gate every recipe sits behind

Verification runs before any predicate, over the raw bytes.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function isSignedByGitHub(rawBody, signatureHeader) {
  const key = process.env.GITHUB_WEBHOOK_SIGNING_VALUE;
  if (!key || !signatureHeader) return false;

  const expected = Buffer.from(
    `sha256=${createHmac('sha256', key).update(rawBody).digest('hex')}`,
    'utf8',
  );
  const received = Buffer.from(signatureHeader, 'utf8');

  // timingSafeEqual throws on a length mismatch, so check length first.
  return expected.length === received.length && timingSafeEqual(expected, received);
}

rawBody is a Buffer or the exact string the request carried. Re-serializing a parsed object produces different bytes and the comparison will never succeed.

1. A pull request merged into the default branch

The capture predicate for a release ledger. It must not fire for a pull request that was closed without merging, nor for merges into a release or feature branch.

function isMergeToDefaultBranch(payload) {
  return payload.action === 'closed'
    && payload.pull_request.merged === true
    && payload.pull_request.base.ref === payload.repository.default_branch;
}

Reads: action, pull_request.merged, pull_request.base.ref, repository.default_branch.

Natural key for deduplication: repository.full_name plus number.

Why not merged_at: it is populated on a merge, so testing it works, but merged states the intent directly and does not require a null check.

2. A tag push

Tag creation arrives two ways. Prefer create; use the push form only when you also need the commit the tag points at.

// event: create
function isTagCreated(payload) {
  return payload.ref_type === 'tag';
}

// event: push  — same fact, with the target SHA
function isTagPush(payload) {
  return payload.ref.startsWith('refs/tags/')
    && payload.created === true;
}

Reads: ref_type and ref on create; ref and created on push.

Trap: on a push that deletes a ref, after is forty zeros and head_commit is null. Test deleted before dereferencing head_commit anywhere.

const isRefDeletion = payload.deleted === true
  || payload.after === '0'.repeat(40);

3. A check regression on the default branch

"Something that used to pass now fails." Route only completed runs with a bad conclusion, and only for the branch that matters.

const FAILING = new Set(['failure', 'timed_out', 'action_required']);

function isCheckRegression(payload) {
  const run = payload.check_run;
  return payload.action === 'completed'
    && run.status === 'completed'
    && FAILING.has(run.conclusion)
    && run.check_suite.head_branch === payload.repository.default_branch;
}

Reads: action, check_run.status, check_run.conclusion, check_run.check_suite.head_branch, repository.default_branch.

Deliberately excluded conclusions: cancelled (someone stopped it), neutral, skipped, and stale (superseded by a newer run). Alerting on those trains people to ignore the alert.

Natural key: check_run.head_sha plus check_run.name. A rerun of the same check on the same commit is the same fact.

4. The same signal from the older Commit Status API

Some integrations never moved to checks. This is the equivalent predicate.

function isStatusFailure(payload) {
  return (payload.state === 'failure' || payload.state === 'error')
    && payload.branches.some((b) => b.name === payload.repository.default_branch);
}

Reads: state, branches[].name, repository.default_branch.

status has no action field. branches lists the branches whose head is this commit, which is how you scope a commit-keyed event to a branch.

5. An approving review

function isApproval(payload) {
  return payload.action === 'submitted'
    && payload.review.state.toLowerCase() === 'approved';
}

Reads: action, review.state.

Trap: the webhook delivers review.state lower-cased where the REST API returns it upper-cased. Lower-case both sides rather than remembering which source a value came from.

6. A comment command, on a pull request only

function parseCommand(payload) {
  if (payload.action !== 'created') return null;
  if (!payload.issue.pull_request) return null;      // an issue, not a PR

  const match = /^\/(\w+)(?:\s+(.*))?$/m.exec(payload.comment.body.trim());
  return match ? { name: match[1], args: match[2] ?? '' } : null;
}

Reads: action, issue.pull_request (presence only), comment.body.

Trap: issue_comment covers both issues and pull requests. The pull_request key on issue is present only for the latter, and that presence check is the only reliable discriminator. Also ignore comments whose sender.type is Bot unless you want a command loop.

7. A successful deployment to a named environment

function isProductionDeploySucceeded(payload) {
  return payload.deployment_status.state === 'success'
    && payload.deployment.environment === 'production';
}

Reads: deployment_status.state, deployment.environment.

Trap: read the environment from deployment, not from deployment_status. Both carry the field, and the deployment record is the authoritative one.

8. A workflow run that finished, for a specific workflow

function isReleaseWorkflowCompleted(payload) {
  return payload.action === 'completed'
    && payload.workflow_run.conclusion === 'success'
    && payload.workflow.path === '.github/workflows/release.yml';
}

Reads: action, workflow_run.conclusion, workflow.path.

Trap: match on workflow.path, not on workflow_run.name. The display name is editable in the workflow file and changing it silently unsubscribes you.

9. A published release that is not a prerelease

function isStableRelease(payload) {
  return payload.action === 'published'
    && payload.release.draft === false
    && payload.release.prerelease === false;
}

Reads: action, release.draft, release.prerelease.

Alternative: subscribe to the released action instead, which fires only for non-prereleases. Use one approach or the other; using both double-fires.

10. A push that touched a path

For path-scoped rebuilds without a full checkout.

function touchesPath(payload, prefix) {
  return payload.commits.some((c) =>
    [...c.added, ...c.removed, ...c.modified].some((p) => p.startsWith(prefix)));
}

Reads: commits[].added, commits[].removed, commits[].modified.

Trap: the commits array is truncated on large pushes, so a negative answer is not proof. When the push is large — or when commits.length equals whatever cap you observe — fall back to comparing before..after through the API.

Recording the routing decision

Whatever the predicates, log one line per delivery with the event name, the action, the delivery id, the predicate that matched (or unrouted), and the response code. That log is what turns "we think we missed a merge" into a five-minute answer, and it is the only place the delivery id belongs.