GitHub Action

Semfora Gate

A policy gate on every pull request. You write the policy in asemfora.tomlat the repo root; the action checks each PR against it and turns the check red when the policy says so. Verdicts are deterministic: the same change gets the same verdict, every run.

Quick start

1. Connect the repository on semfora.ai (installs the GitHub App). The gate rides on that connection.

2. Mint a license key in your portal and add it to the repo as the SEMFORA_KEY secret:

gh secret set SEMFORA_KEY --repo your-org/your-repo

3. Add the workflow:

name: Semfora Gate
on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
  pull_request_review:
    types: [submitted] # re-run when a reviewer approves (waiver flow)
  issue_comment:
    types: [created] # "@semfora <reason>" comments re-run a failed gate

permissions:
  pull-requests: write # report comment, line comments, denial review
  actions: write # the reason-comment run re-runs the failed gate run

jobs:
  gate:
    # issue_comment fires for plain issues too; only PR comments matter.
    if: github.event_name != 'issue_comment' || github.event.issue.pull_request
    runs-on: ubuntu-latest
    steps:
      # No checkout: analysis runs in Semfora's pipeline via the repo's
      # GitHub App connection. Nothing executes on this runner.
      - uses: Semfora-AI/semfora-action@v2
        with:
          semfora-key: ${{ secrets.SEMFORA_KEY }}
          require-reason: "true"

Nothing runs on your runner

The action is a thin client: one dependency-free file, no checkout step, no binary. It asks Semfora's pipeline to analyze the PR head over the repo's existing GitHub App connection, then polls for the verdict. If the merge webhook already analyzed that commit, the action reuses the same run instead of starting a second one. The report contains symbol names and numbers only, never source text.

Authentication

The gate authenticates with a Semfora license key, passed as the semfora-key input and stored as a repository or organization secret named SEMFORA_KEY. Keys are minted in your Semfora portal.

A key only gates its own repos

Every API call verifies the license key and that the key's billing account owns the repository: an organization key gates the repos connected to that organization, an individual key gates personal repos. Invalid keys, revoked keys, and other accounts' repos all get the same uniform rejection, so a rejection never reveals whether a repo exists.

How the key travels

The key is only ever sent over https, rides in POST bodies rather than URLs, and is masked in workflow logs. Rotating it is a portal mint plus a secret update; nothing in the repo changes.

Bot identity

For repos connected via the Semfora GitHub App, the report comment and review post as semfora[bot], the same identity as the semfora.ai check runs, and PR surfaces keep working on fork PRs where the workflow token is read-only. Without that, the action falls back to the workflow token and GitHub renders it as github-actions[bot].

The policy: semfora.toml

Policy lives in your repo and versions with your code. Severity meanings: error fails the gate (red check plus a Changes Requested review naming the domain, waivable by a qualified approval), warn annotates and reports but passes, and off disables the rule. A working example:

# semfora.toml at the repo root. One file drives the GitHub Action,
# the semfora.ai cloud gate, and local runs.

# Groups are the vocabulary your rules speak: file globs or modules.
[groups.billing]
description = "Money: plans, quotas, subscriptions, licensing"
files = ["src/server/billing*.ts", "src/server/planPolicy.ts"]

[groups.pipeline]
description = "The background analysis pipeline"
modules = ["src.trigger"]

# Touching a protected group turns the PR red and files a
# Changes Requested review naming the domain.
[gate.protected]
severity = "error"
groups = ["billing"]

# Edits to high fan-in, load-bearing symbols get flagged.
[gate.load_bearing]
severity = "warn"
threshold = 60

# Tier-0 domains tolerate less.
[gate.load_bearing.groups.billing]
threshold = 45

# New cross-module dependencies are usually layering drift.
[gate.new_coupling]
severity = "warn"

# Complexity budgets per changed symbol: a ceiling, and a cap on
# how much one diff may raise a symbol's complexity.
[gate.complexity]
severity = "warn"
max_cc = 25
max_cc_delta = 10

# Money code stays simple or it stays out.
[gate.complexity.groups.billing]
severity = "error"
max_cc = 20

[gate.protected]

The domains where any edit needs a human decision: billing, auth boundaries, data handling. Touching a protected group raises a finding at the configured severity.

[gate.load_bearing]

Flags edits to symbols above a load-bearing threshold: code with high fan-in that much of the system depends on. Per-group overrides let critical domains use a stricter threshold.

[gate.new_coupling]

Surfaces new cross-module dependencies introduced by the PR. These are usually layering drift that nobody decided on purpose.

[gate.complexity]

Cognitive-complexity budgets per changed symbol: max_cc is the ceiling, max_cc_delta caps how much a single diff may raise one symbol's complexity. Per-group overrides can tighten both, or raise the severity to error.

What a failure looks like, and how it clears

On a policy error the check turns red and the gate projects its report onto the PR: one sticky comment per PR, edited in place on every run, with a findings table linking each hit to the exact line at the head commit, plus a before/after complexity chart and a module dependency graph when the data warrants them. The verdict and line annotations land as a single review, and when a protected domain was hit at error severity that review is Changes Requested, naming the domains. Analysis problems fail the step with an explanatory error instead of a policy verdict, so a broken run never masquerades as a denial. From there a policy failure clears one of three ways:

Fix the finding

Push a new commit that satisfies the policy. The gate re-runs, passes, and dismisses its own review.

Explain it: the reason flow

With require-reason: "true", anyone qualified comments @semfora <why this change is needed> (10+ characters). Qualified means the PR author or a repo collaborator, so on fork PRs the outside contributor can unblock themselves by explaining; bots never qualify, and a bare tag is not a reason. The comment re-runs the failed gate, every Semfora check on the PR flips green, and the reason is recorded on semfora.ai tied to the gate run, the restricted domains, and the measured quality deltas it justified. Reasons are not pinned to a commit: an explanation of intent survives follow-up pushes.

Approve it: the reviewer flow

With require-approval, a qualified reviewer approves the PR at its current head commit. Pushing new commits invalidates the approval, and self-approval never counts. With require-approval: admin the approval must come from a repo admin. The pull_request_review trigger in the workflow makes the check re-run on approval, and marking the check required in branch protection is what makes the gate binding rather than advisory. When both waiver inputs are set, a failure needs the approval and the reason.

Inputs

semfora-keydefault: required

Your Semfora license key. Store it as a repository or organization secret; the key must belong to the account that owns the repo's Semfora installation.

basedefault: PR base sha

Base sha to gate against. Rarely needed.

poll-timeout-minutesdefault: 20

How long to wait for the cloud run before failing the step. First-time indexes of very large repos may need more.

required-reviewersdefault: none

GitHub usernames or org/team slugs to request as reviewers. Entries are validated; people without repo access are skipped with a warning.

request-reviewers-ondefault: fail

When to file the review request: "fail" (only on policy errors), "always", or "never".

require-approvaldefault: false

"true": a policy failure only passes once a required reviewer approves the current head commit (stale approvals do not count). "admin": the approval must come from a repo admin. Combinable with require-reason: with both set, a failure needs the approval AND the reason.

require-reasondefault: false

A failing gate stays red until someone explains the change in a PR comment mentioning @semfora (10+ characters of reason, from the PR author or a repo collaborator, never bots). The comment re-runs the gate via the issue_comment trigger, and the accepted reason is recorded on semfora.ai with the domains and quality deltas it covers.

factsdefault: on

The report comment includes a "Facts introduced" section: AST-proven properties this change adds (empty catch handlers, returns in finally, exit-free loops, per-iteration IO, identical branches, self-comparisons, literal if-conditions, discarded async promises), AI-triaged server-side so misreads and test-scaffolding noise stay hidden. "off" removes the section.

line-commentsdefault: true

Annotate the exact lines the policy rules hit, deduplicated across runs. Hits outside the diff stay in the report comment.

request-changesdefault: true

While the verdict is fail, submit the gate review as Changes Requested naming the restricted domains; dismissed automatically once the gate passes or the failure is waived.

pr-commentdefault: on-findings

Post the gate report as a sticky PR comment, edited in place each run: linked findings, the touched domains colored to match the semfora.ai dashboard, the measured quality impact, and the generated graphs. "on-findings", "always", or "never".

api-urldefault: https://semfora.ai

Semfora API base. Self-hosted deployments only.

github-tokendefault: workflow token

Token for the reviewer API calls. Only set it if you want reviewer requests to come from a bot or app identity.

Outputs

For chaining into later workflow steps:

verdict

"pass" or "fail", before any waiver.

errors

Number of error-severity findings.

warnings

Number of warning-severity findings.

waived

"true" when a policy failure was waived: by a qualified approval of the current head commit (require-approval), an accepted @semfora change reason (require-reason), or both when both are configured.

denied-domains

Comma-separated restricted domains hit at error severity; empty when none.

domains-touched

Comma-separated domains (semfora.toml groups) whose symbols this PR changes, denied or not.

reason

The accepted @semfora change reason (single line, capped at 500 chars; empty when none was given or require-reason is off).

reason-author

GitHub login of whoever provided the accepted reason.

We gate our own repo with this

The first pull request this gate denied was a billing change in the Semfora web app. The check went red, the review was filed automatically, a reviewer approved the change on its merits, and it merged with the decision on record. That loop, evidence, decision, record, is the point.