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-repo3. 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
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: requiredYour 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 shaBase sha to gate against. Rarely needed.
poll-timeout-minutesdefault: 20How long to wait for the cloud run before failing the step. First-time indexes of very large repos may need more.
required-reviewersdefault: noneGitHub usernames or org/team slugs to request as reviewers. Entries are validated; people without repo access are skipped with a warning.
request-reviewers-ondefault: failWhen 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: falseA 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: onThe 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: trueAnnotate the exact lines the policy rules hit, deduplicated across runs. Hits outside the diff stay in the report comment.
request-changesdefault: trueWhile 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-findingsPost 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.aiSemfora API base. Self-hosted deployments only.
github-tokendefault: workflow tokenToken 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.
errorsNumber of error-severity findings.
warningsNumber 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-domainsComma-separated restricted domains hit at error severity; empty when none.
domains-touchedComma-separated domains (semfora.toml groups) whose symbols this PR changes, denied or not.
reasonThe accepted @semfora change reason (single line, capped at 500 chars; empty when none was given or require-reason is off).
reason-authorGitHub login of whoever provided the accepted reason.
We gate our own repo with this