Building BranchGuard: A Stateless GitHub App for Programmable Branch Protection

github Feb 2, 2026

If you've ever tried to enforce conditional branch rules in GitHub, you've probably felt the frustration: the built-it protection is powerful but it's also rigid. You can require status checks, mandate reviews and enforce linear history, but you can't express conditions like "only require the frontend linter when frontend files changes" or "block this PR because it's missing a migration file that exists on master or dev".

I needed something more expressive: protection rules that are aware of which files changed and can adapt accordingly.

So I built BranchGuard which is a stateless GitHub App that evaluates configurable rules against pull requests and posts pass/fail check runs. No database, no CI runner, no polling loops. Just webhooks, the GitHub API and a YAML config file.

This post walks through the architectural decisions, the tricky parts of building on top of GitHub's Checks API and the patterns I used to keep the system resilient under real-world conditions.

The Problem Space

Consider a .NET monorepo with Entity Framework migrations. When a developer opens a PR against dev, they might have the latest application code but be missing a migration file that another just merged. The EF tooling won't catch this at build time and it only fails at runtime when the app tries to apply migrations against the database schema.

Or consider a monorepo with a frontend and backend. You have a frontend-lint CI check, but it runs on every PR even when only backend files changed. Sure, you could use path filters in GitHub Actions, but then the check never reports on irrelevant PRs, and if you've marked it as a required status check, thos PRs get stuck waiting for a check that will never arrive.

These are the two core problems BranchGuard solves:

  1. Conditional enforcement - rules that fire based on which files changed.
  2. Branch protection compatibility - always posting a check run (even a passing one), so rules can be marked as required status checks without blocking unrelated PRs.

Architecture Overview

BranchGuard is build on Probot v13, the GitHub App framework. The tech stack is deliberately minimal:

  • TypeScript on Node.js
  • Probot v13 for webhooks handling and GitHub API auth
  • Zod v4 for config validation
  • picomatch for glob pattern matching
  • pino for structure logging

The architecture follows a strict layered pattern:

Webhooks → Handlers → Evaluate Service → Check Types → GitHub API
                          ↓
                    Config Service (cached)
                    File Matcher (picomatch)
                    GitHub Trees (cached)

Every webhook handler follows the same flow: load config, fetch changed files, call the shared evaluation pipeline. The evaluation pipeline filters rules by branch, matches files against glob patterns, executes the relevant check type and posts/updates check runs.

Why Stateless?

The entire application state comes from two sources: the GitHub API and the .github/branch-guard.yml config file. There's no database, no Redis, no persistent storage.

A stateless app:

  • Deploys trivially - one Docker container, zero infrastructure dependencies.
  • Scales horizontally - multiple instances can handle webhooks independently
  • Recovers from crashes - restart and pick up where you left off via webhook replay
  • Keeps the blast radius small - if the app goes down, PRs aren't blocked (check's just don't update)

The one trade-off is the external_status check type, which tracks pending evaluations in an in-memory Map. If the process restarts, those pending entries are lost but BranchGuard compensates with fallback re-evaluations on subsequent PR events (e.g a /recheck command in the comment).

The Type System

I wanted the config to be both flexible and strictly validated. Zod v4's discriminatedUnion made this possible with a single schema that handles five different check types:

export const RuleSchema = z.discriminatedUnion("check_type", [
  FilePresenceRuleSchema,
  FilePairRuleSchema,
  ExternalStatusRuleSchema,
  BranchAgeRuleSchema,
  ApprovalGateRuleSchema,
]);

export const ConfigSchema = z.object({
  rules: z.array(RuleSchema).min(1).max(20),
});

Each rule variant shares a base set of fields (name, description, on, failure_message, notify) but has its own config block validated by a type-specific schema. The discriminant is check_type, so Zod knows exactly which schema to apply based on that single field.

This gives you narrowing for free at the type level. When you check rule.check_type === "file_pair", TypeScript knows rule.config has companion and mode properties. No type assertions are needed in the happy path.

Rule names are validated with a regex (/^[a-z0-9-]+$/) because they become part of check run names: branch-guard/{ruleName}. This namespacing prevents collisions with other apps or CI checks.

The ApprovalGateConfigSchema uses Zod's .refine() for a cross-field validation that can't be expressed with primitive validators alone where at least one of required_teams or required_usersmust be non-empty:

const ApprovalGateConfigSchema = z.object({
  required_teams: z.array(z.string()).min(1).optional(),
  required_users: z.array(z.string()).min(1).optional(),
  mode: z.enum(["any", "all"]).optional().default("any"),
  auto_request_reviewers: z.boolean().optional().default(false),
}).refine(
  (data) => (data.required_teams && data.required_teams.length > 0) ||
            (data.required_users && data.required_users.length > 0),
  { message: "At least one of required_teams or required_users must be provided" },
);

This is a good example of the boundary between structural validation (each field has the right type) and semantic validation (the combination of fields makes sense). Zod handles both in one pass, and the error message surfaces directly in the config check run when validation fails.

The Config Validation Pipeline

Config loading is a three-stage pipeline: fetch, parse and validate. Each stage can fail independently and the failure modes are modeled as a discriminated union:

export type ConfigLoadResult =
  | { status: "loaded"; config: Config }
  | { status: "missing" }
  | { status: "invalid"; errors: string[] };

The missing state is not an error, it simply means that the repository hasn't adopted BranchGuard and all handlers silently skip. The invalid state posts a dedicated branch-guard/config check run with the Zod validation errors formatted as a bulleted list, so the developer can see exactly what's wrong without digging through logs.

The Evaluation Pipeline

The core of BranchGuard is evaluateRules(); a single function that every handler calls with the same parameters:

interface EvaluateParams {
  octokit: Octokit;
  owner: string;
  repo: string;
  pr: PullRequestContext;
  config: Config;
  logger: Logger;
}

The pipeline works in stages:

  1. Filter by branch: Only rules where rule.on.branches includes the PR's base branch are evaluated. This means the same config can contain master, dev and staging without conflict.
  2. Match files: For each applicable rule, picomatch checks if any changed files match the include patterns (minus exclude). If nothing matches, BranchGuard posts a passing check run with "Rule not applicable" and moves on. This is critical for branch protection compatibility where the check exists on every PR, so it can be marked as a required status check.
  3. Execute the check type: The check registry looks up the implementation by check_type and calls execute(). Each check type is a class implementing a CheckType interface with a single execute(ctx: CheckContext): Promise<CheckResult> method.
  4. Apply overrides: If a rule has a failure_message and the result is a failure, the custom title/summary replaces the defaults. This lets teams write context-specific failure messages like "Missing migrations: see wiki/migrations for help" instead of generic "Missing N files(s)".
  5. Post results: The check run is updated with the final conclusion. Failures trigger a stick PR comment notification.

Each rule is evaluated independently using Promise.allSettled(). If rule A throws an exception, rules B through E still complete normally. The failed rule gets a "internal error" check run so the developer knows something went wrong.

The Check Run Lifecycle: Create, Find & Update

A subtle challenge with the Checks API is idempotency. When BranchGuard evaluates a rule, it needs to decide: does a check run already exist for this rule on this commit or do I need to create one?

The answer depends very much on the context. The first evaluation for a PR creates check runs. But a recheck command, a base branch push or a re-requested check suite should update the existing check runs rather than creating duplicates.

The evaluateSingleRule() function handles this with a find-or-create pattern:

const existing = await findCheckRun(octokit, owner, repo, pr.headSha, name);
let checkRunId: number;

if (existing) {
  checkRunId = existing.id;
  await updateCheckRun(octokit, { owner, repo, checkRunId, status: "in_progress" });
} else {
  checkRunId = await createCheckRun(octokit, {
    owner, repo, headSha: pr.headSha, name, status: "in_progress",
  });
}

The findCheckRun() function requires the Checks API with a check_name filter and per_page: 1, which returns the most recent checks with that name on the commit. This is efficient because it utilizes a single API call regardless of how many check runs exist.

There's a critical detail in createCheckRun(): the conclusion field is only included when status === "completed". GitHub's API rejects a conclusion on an in_progress check_run. This conditional inclusion prevents a subtle 422 error.

conclusion: params.status === "completed" ? params.conclusion : undefined,

Webhook Event Selection

BranchGuard subscribes to eight webhook events. Each exists for a specific reason:

pull_request.opened / synchronize / reopened are the primary triggers. A new PR, a new push to the PR branch or a reopened PR all need fresh evaluation.

pull_request.edited  but only when the base branch or PR body changes. A title edit doesn't affect check results, but changing the base branch changes which rules apply and editing the body might update the file deletion allowlist.

pull_request_review.submitted triggers re-evaluation for approval_gate checks. When someone approves or requests changes, the approval status has changed and needs rechecking.

push re-evaluates open PRs when the base branch changes. If a new migration is merged to dev, all open PRs targeting dev need to be rechecked for that migration's presence.

check_run.completed reactively resolves pending external_status evaluations when an external CI check finishes. Without this, BranchGuard would need to poll.

check_suite.rerequested handles the "Re-run all checks" button in the GitHub UI. This webhook carries the associated PRs, so BranchGuard can re-evaluate them.

issue_comment.created tracks the /recheck command. GitHub doesn't have a native "re-run this specific app's checks" mechanism, so a comment-based command fills the gap.

installation.created / installation_repositories.added backfills checks on existing PRs when the app is installed.

The Infinite Loop Guard

The check_run.completed handler has a critical self-referential problem: BranchGuard's own check runs fire check_run.completed webhooks. If the handler processed its own completions, it would create an infinite loop.

The fix is a prefix check on the first line of the handler:

if (completedCheckName.startsWith(CHECK_NAME_PREFIX + "/")) {
  return;
}

This is simple but essential. Every BranchGuard check run is named branch-guard/{ruleName}, so the prefix match is a reliable filter.

Check Types in Depth

file_presence: Detecting Missing Files Across Branches

This is the check type that originally motivated the project. It fetches the Git tree for both the base and head SHAs, filters them by the rule's glob patterns and computes the set difference:

missingFiles = baseFiles.filter(f => !headSet.has(f))

If any files exists on the base branch but not on the head branch, the check fails. But sometimes, the absence of those files may be intentional due to deletions to consolidate migrations or starting over with fresh migrations. For this, BranchGuard supports an allowlist embedded in the PR description as an HTML comment:

<!-- branch-guard:allow
migration-sync: db/migrations/001_init.sql (replaced by consolidated migration)
migration-sync: db/migrations/002_users.sql (merged into 003)
-->

The parse is deliberately lenient; blank lines are ignored, malformed lines are skipped and reasons are optional. The allowlist is scoped per-rule, so you can have different rules with different allowed deletions.

Both trees are fetched in parallel with Promise.all() since they're independent requests:

const [baseFiles, headFiles] = await Promise.all([
  getFilteredTree(ctx.octokit, ctx.owner, ctx.repo, ctx.pr.baseSha, include, exclude),
  getFilteredTree(ctx.octokit, ctx.owner, ctx.repo, ctx.pr.headSha, include, exclude),
]);

file_pair: Companion File Detection

file_pair is the simplest check type. When files matching the trigger patterns change, it verifies that companion files also changed. The implementation normalizes the companion config (string or string array) and uses a Set for O(1) lookups:

const companions = Array.isArray(rule.config.companion)
  ? rule.config.companion
  : [rule.config.companion];
const changedSet = new Set(ctx.pr.changedFiles);

The mode field controls the evaluation:

  • "any" requires at least one companion to be changed
  • "all" requires every companion.

This covers use cases like "package.json must update either package-lock.json or yarn.lock" (any) vs "changing schema.prisma must update both the migration and the client" (all).

branch_age: Measuring Branch Staleness via the Compare API

branch_age uses GitHub's Compare API to find the merge base commit which is the point where the PR branch diverged from the base branch. The merge base commit's committer date is the branch's effective age:

const response = await withRetry(() =>
  ctx.octokit.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
    owner: ctx.owner,
    repo: ctx.repo,
    basehead: `${ctx.pr.baseSha}...${ctx.pr.headSha}`,
  }),
);

const mergeBaseDate = response.data.merge_base_commit.commit.committer?.date;
const ageDays = Math.floor(
  (Date.now() - new Date(mergeBaseDate).getTime()) / (1000 * 60 * 60 * 24),
);

This is more accurate than looking at the first commit on the branch, because a rebased branch will have a recent merge base even if the original commits are old. The check fails with a message suggesting a rebase, which is exactly the action that would reset the branch age.

external_status: Making CI Checks Conditionally Required

This was the trickiest check type to get right. The idea is simple: when frontend files change, require the frontend-lint check to pass. When no frontend files change, auto-pass.

The challenge is timing. When a PR is opened or updated, the external CI checks haven't started yet. BranchGuard can't just query the check runs and expect them to exist.

I solved this with a hybrid event-driven + fallback approach:

  1. On initial evaluation, BranchGuard queries all check runs on the head SHA.
  2. If required checks are still pending or missing, BranchGuard stores a PendingEvaluation in an in-memory Map and leaves its own check run as in_progress.
  3. BranchGuard listens for check_run.completed webhooks. When a required check finishes, the handler looks up any pending evaluations waiting on that check and re-queries the full status.
  4. If all required checks have now passed, BranchGuard resolves its own check run to success. If any failed, failure. If some are still pending, it stays in_progress.
  5. A configurable timeout (default 30 minutes) prevents evaluations from hanging indefinitely.

The fallback path covers the case where the process restarts and loses its pending evaluations — the next PR event triggers a fresh evaluation.

const pendingEvaluations = new Map<string, PendingEvaluation>();

// Key format: owner/repo:headSha:ruleName
export function getPendingKey(
  owner: string, repo: string, headSha: string, ruleName: string
): string {
  return `${owner}/${repo}:${headSha}:${ruleName}`;
}

This approach avoids polling entirely. No cron jobs, no setInterval. Just webhook events and a Map.

approval_gate: CODEOWNERS, But Per-Rule

GitHub's CODEOWNERS file is all-or-nothing where if you match a path, the assigned team must approve. approval_gate gives you the same idea but scoped to individual rules with more control.

The check fetches all PR reviews, deduplicates to the latest review per user and evaluates against the configured requirements. It supports two modes:

  • mode: "any" — at least one required team/user has approved
  • mode: "all" — every required team/user has approved

A key design choice: CHANGES_REQUESTED acts as a hard blocker. If any required reviewer has requested changes, the check fails immediately regardless of other approvals. This matches the mental model that "changes requested" means "not ready."

The optional auto_request_reviewers flag will automatically request review from missing teams/users when the check fails but only when the failure is due to missing approvals and not changes requested. This avoids nagging reviewers who already know they need to re-review.

Review Deduplication and State Filtering

The implementation uses a Map keyed by lowercase username, overwriting on each iteration:

const latestByUser = new Map<string, LatestReview>();
for (const review of allReviews) {
  const username = review.user.login.toLowerCase();
  if (review.state === "COMMENTED" || review.state === "DISMISSED") continue;
  latestByUser.set(username, { user: username, state: review.state });
}

COMMENTED and DISMISSED reviews are filtered out because they don't represent an approval decision. Only APPROVED and CHANGES_REQUESTED carry weight.

All username comparisons are case-insensitive (lowercased). GitHub usernames are case-insensitive but the API may return different casings in different contexts. Without normalization, JohnDoe wouldn't match johndoe and the approval would be missed.

Handling Scale: Caching, Batching, and Retries

Caching Strategy

BranchGuard makes a lot of GitHub API calls. A single push event might trigger re-evaluation of dozens of open PRs, each requiring config + tree + files API calls. Without caching, you'd burn through your rate limit in minutes.

The caching layer is a simple in-memory TTL cache:

export class TtlCache<T> {
  private store = new Map<string, { value: T; expiresAt: number }>();
  private readonly ttlMs: number;

  get(key: string): T | undefined {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    if (Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return undefined;
    }
    return entry.value;
  }

  set(key: string, value: T): void {
    this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
  }
}

Two caches with 60-second TTLs:

  • Config cache (owner/repo:ref) — the same config is loaded once per evaluation burst
  • Tree cache (owner/repo:sha) — Git trees are immutable by SHA, so this is safe to cache indefinitely in theory. The 60s TTL is just to bound memory usage.

This is enough to reduce the API calls from O(rules * PRs) to O(1) per config and O(unique SHAs) per tree during push handler fan-out.

The Push Handler: Batch Processing at Scale

When someone pushes to dev, every open PR targeting dev might need re-evaluation. For repos with dozens of open PRs, this could mean hundreds of API calls in a single webhook handler.

The push handler mitigates this with batched processing:

const PR_BATCH_SIZE = 5;
const BATCH_DELAY_MS = 500;

for (let i = 0; i < openPrs.length; i += PR_BATCH_SIZE) {
  const batch = openPrs.slice(i, i + PR_BATCH_SIZE);

  await Promise.allSettled(
    batch.map(async (pr) => {
      const changedFiles = await getPrChangedFiles(...);
      await evaluateRules(...);
    }),
  );

  if (i + PR_BATCH_SIZE < openPrs.length) {
    await delay(BATCH_DELAY_MS);
  }
}

Five PRs are processed concurrently, then a 500ms delay before the next batch. Promise.allSettled() ensures one failing PR doesn't abort the batch. Combined with the config/tree caching, this keeps the API usage bounded even for large repositories.

Retry and Rate Limit Handling

GitHub's API returns 429 (rate limit) and 5xx (transient) errors under load. Every API call in BranchGuard is wrapped in a withRetry() function that implements exponential backoff:

export async function withRetry<T>(
  fn: () => Promise<T>,
  opts?: RetryOptions,
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error: any) {
      if (attempt === maxRetries || !isRetryable(error)) throw error;

      const retryAfterMs = getRetryAfterMs(error);
      const delayMs = retryAfterMs ?? baseDelayMs * Math.pow(2, attempt);
      await sleep(delayMs);
    }
  }
}

The function respects the Retry-After header when present (taking precedence over the exponential calculation) and distinguishes rate-limited 403s from permission 403s by checking the x-ratelimit-remaining header. Only genuinely transient errors get retried while a 404 or permission error fails immediately.

UX Touches That Matter

The Recheck Command

GitHub doesn't expose a native way to say "re-run this specific app's checks". BranchGuard solves this with a comment-based command. Posting /recheck or /branch-guard recheck on a PR triggers a fresh evaluation:

const RECHECK_COMMANDS = ["/recheck", "/branch-guard recheck"];

const body = payload.comment.body.trim().toLowerCase();
if (!RECHECK_COMMANDS.some((cmd) => body === cmd)) return;

After processing, the /recheck comment is deleted to keep the PR timeline clean. This deletion is best-effort, so if the app lacks the permission, it logs a warning and continues with the recheck.

PR Comment Notifications

GitHub doesn't send notifications for third-party check run failures. If your CI fails, you get an email. If a GitHub App's check run fails, silence. This is a real UX problem and developers don't check the Checks tab obsessively.

BranchGuard compensates with a sticky PR comment. When any check fails, it posts (or updates) a comment with a failure table:

## Branch Guard: N check(s) failed

| Rule | Result | Details |
|------|--------|---------|
| `migration-sync` | Failed | Missing 2 file(s) from main |

> Resolve issues and push again, or recheck — comment `/recheck` to re-evaluate.

The comment uses a hidden HTML marker (<!-- branch-guard-status -->) so BranchGuard can find and update it on subsequent evaluations. The first comment triggers a GitHub notification; subsequent updates are silent (in-place edits don't re-notify).

When all checks eventually pass, the comment is updated to show success, so the PR timeline tells the full story.

Graceful Degradation

A design principle that runs through the entire codebase: non-critical operations must never crash the critical path.

The PR comment system is the clearest example. Every function in pr-comment.ts wraps its entire body in a try/catch and logs errors instead of throwing:

export async function postOrUpdateFailureComment(...): Promise<void> {
  try {
    // ... all the comment logic
  } catch (error) {
    logger.error({ error }, "Failed to post/update PR comment — skipping notification");
  }
}

If the app doesn't have the issues:write permission (a common misconfiguration), or if the Issues API is transiently down, the check runs still get posted. The developer loses the PR comment notification but still sees results in the Checks tab.

Deployment

BranchGuard deploys as a single Docker container. The Dockerfile uses a multi-stage build: compile TypeScript in a build stage, then copy only dist/ and production dependencies into a slim runtime image.

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/package*.json ./
RUN npm ci --production
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

The build stage installs all dependencies (including devDependencies like TypeScript), compiles to JavaScript, and then the production stage starts fresh with only production dependencies plus the compiled output.

The project deploys to Fly.io with a minimal configuration: 256MB RAM, one CPU, auto-stop/start. A GitHub Actions workflow triggers deployment on push to master when source files or the Dockerfile change.

The /healthz endpoint returns { "status": "ok" } for container orchestration probes.

Takeaways

Building BranchGuard reinforced a few principles:

Statelessness is a feature, not a limitation: By deriving all state from the GitHub API and a config file, the app is trivial to deploy, scale, and recover. The one exception (pending external_status evaluations in memory) is explicitly designed with a fallback path.

Branch protection compatibility matters: If your GitHub App creates check runs that can be marked as required status checks, you must always create those checks — even when they don't apply. Otherwise, PRs get stuck waiting for a check that never arrives.

GitHub's notification model has gaps: Third-party check failures are silent. If your app is supposed to inform developers about problems, you need to build your own notification layer.

Promise.allSettled() is the right default for independent operations: When evaluating multiple rules, one failure shouldn't cascade to the others. allSettled gives you isolation without try/catch boilerplate around each operation.

Caching and batching are not optimizations, they're requirements: The GitHub API rate limit is 5,000 requests per hour for installations. A single push to a busy repo can easily burn through hundreds of calls. Without caching and batching, BranchGuard would be unusable on repositories with more than a handful of open PRs.


The source code is available on GitHub at https://github.com/lawale/branch-guard. It's MIT-licensed and self-hostable with a single Docker command.

If you've faced similar branch protection challenges, give BranchGuard a try or star it on GitHub. I'd love to hear how you're using it.

Tags

Views: Loading...