Skip to content

Repository files navigation

contribution-checker

Self-hosted Node.js app that gates pull requests behind a contributor application form. A GitHub App watches pull_request.opened events on linked repos. If the PR author hasn't been approved for that project, the App closes the PR with a friendly comment pointing to a public application page. Maintainers triage applications in a dashboard. On approval, previously-closed PRs are automatically reopened.

Stack

  • Next.js 15 (App Router) + Prisma + SQLite
  • Auth.js v5 (GitHub OAuth) for sign-in
  • GitHub App for repo automation (PR close/reopen/comment/label)
  • Tailwind v4 + shadcn-style UI primitives
  • Vitest for unit tests
  • Docker + docker-compose for self-hosting

Quick start (local development)

pnpm install
cp .env.example .env
# Fill in AUTH_SECRET (openssl rand -base64 32), AUTH_GITHUB_ID/SECRET
# (create an OAuth app at https://github.com/settings/developers)
# Add your GitHub login to SUPER_ADMINS so you can manage allowlist + setup.
pnpm exec prisma migrate dev
pnpm dev
# Open http://localhost:3000, sign in with GitHub.
# Visit /admin/setup to bootstrap the GitHub App via manifest.

Production (Docker)

cp .env.example .env
# Fill AUTH_SECRET, AUTH_GITHUB_ID/SECRET, PUBLIC_BASE_URL, SUPER_ADMINS, SMTP_*.
# Update docker/Caddyfile with your domain.
docker compose -f docker/docker-compose.yml up -d
# 1) Visit https://your-domain.example/admin/setup as a super-admin.
# 2) Click the manifest button to create the GitHub App on github.com.
# 3) GitHub redirects back; copy the printed env block into .env.
# 4) docker compose -f docker/docker-compose.yml restart app.
# 5) Confirm at /admin. The App is now configured.

GitHub App setup walkthrough

The GitHub App is what gives the system permission to receive PR webhooks and to close/reopen/label PRs. It's separate from the OAuth App used for human sign-in. Set it up once per instance.

1. Prerequisites

  • The instance is reachable on a public URL (PUBLIC_BASE_URL in .env). Local development with a tunnel like ngrok works too; just use the tunnel URL.
  • Your GitHub login is in SUPER_ADMINS in .env. Sign in once so the flag gets applied.

Single-App mode (recommended): one GitHub App handles both human sign-in and repo automation. Leave AUTH_GITHUB_ID/SECRET blank; the App's OAuth client_id/secret are reused for sign-in. The manifest at /admin/setup registers the right OAuth callback automatically.

Two-App mode: if you want a separate OAuth App for sign-in, create one at https://github.com/settings/developers with callback https://your-domain.example/api/auth/callback/github and put its credentials in AUTH_GITHUB_ID/SECRET. Those take precedence over the App's OAuth credentials when set.

2. Create the App on GitHub (manual)

/admin/setup is a public page that shows the exact URLs you need. Open it once to copy them, then create the App at https://github.com/settings/apps/new (or under your org).

Field on github.com Value
Homepage URL ${PUBLIC_BASE_URL}
Webhook URL ${PUBLIC_BASE_URL}/api/github/webhook
Webhook secret random string (openssl rand -hex 32), stored in GITHUB_APP_WEBHOOK_SECRET
Callback URL ${PUBLIC_BASE_URL}/api/auth/callback/github

Permissions:

  • Repository: Pull requests: Read & write
  • Repository: Issues: Read & write
  • Repository: Metadata: Read (auto-selected)
  • Account: Email addresses: Read (so the app can email applicants)
  • Organization: Members: Read (optional, only for the auto-bypass-collaborators feature on org repos)

Subscribe to events: Pull request, Installation target, Installation repositories.

After creating the App:

  1. Generate a private key (downloads a .pem file).
  2. Note the App ID and Client ID, and click Generate a new client secret.
  3. Fill in your .env:
    GITHUB_APP_ID="..."
    GITHUB_APP_SLUG="..."          # last URL segment of the App
    GITHUB_APP_CLIENT_ID="..."
    GITHUB_APP_CLIENT_SECRET="..."
    GITHUB_APP_WEBHOOK_SECRET="..."
    # PEM on a single line with literal \n between lines, in double quotes:
    GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
    # Single-App mode: leave AUTH_GITHUB_ID/SECRET blank.
  4. Restart the server. /admin/setup should now show a green "Configured" badge.

3. Install the App on a repo and link it

For every project that should be gated:

  1. Create the project from /dashboardNew project. Choose a slug (this becomes /p/<slug>).
  2. In the project, go to Repos tab → click "Add repo via GitHub App". This sends you to GitHub to install the App. Pick Only select repositories and choose the repo(s).
  3. GitHub redirects you back to a "Link repositories" page in the dashboard. Tick the repos you want gated and Save selection.
  4. Done. Open a PR from a non-allowlisted GitHub account on that repo to test. The PR should be closed within a second or two with a comment pointing to /p/<slug>.

4. Verify

What How
Webhooks reaching your server GitHub App → AdvancedRecent Deliveries should show 200 responses.
Decision flow Open a PR → in the dashboard, Applications queue should not show one (the user hasn't applied yet); a PrCheck row was created with status PENDING.
Public landing Visit /p/<your-slug> while signed out; you should see the apply form gated behind a sign-in button.
Approve flow Submit an application as a second test account, approve it from the dashboard. The PR you opened should reopen automatically and switch labels.

5. Permissions reference

The manifest requests exactly:

  • Repository: Pull requests: read & write (close/reopen/label PRs)
  • Repository: Issues: read & write (PR comments + label CRUD; PRs are issues for these endpoints)
  • Repository: Metadata: read (required by GitHub for any installation)
  • Organization: Members: read (used by the collaborator auto-bypass check)

Subscribed events: pull_request, installation, installation_repositories.

Troubleshooting

  • "GitHub App not configured" after restart: the env loader is strict about the PEM format. Make sure the value in .env is wrapped in double quotes and uses \n for newlines (the setup callback gives you exactly the right format).
  • Webhook deliveries show 401 Invalid signature: GITHUB_APP_WEBHOOK_SECRET doesn't match. Re-check the value in .env.
  • PRs aren't being closed: confirm the repo is linked under the project's Repos tab (not just installed in the GitHub App). The webhook ignores PRs from repos that aren't linked.
  • Collaborators are seeing their PRs blocked: toggle off the "Auto-bypass repository collaborators" option on the project settings page if you want to gate collaborators too, or leave it on if you want them to skip the gate. The check is cached for 5 minutes.

GitHub Actions CI (no App)

For repos where you can't install the GitHub App (no org admin rights, restrictive enterprise policies, etc.), the project supports a parallel ingestion path: drop two workflows into the repo and the gating runs from inside Actions, using the workflow's built-in GITHUB_TOKEN to take action on PRs.

How auth works

Workflows authenticate using GitHub Actions' built-in OIDC token. Each request to the contribution-checker endpoints carries a short-lived JWT minted by GitHub (token.actions.githubusercontent.com). The server verifies the token's signature against GitHub's JWKS and trusts the repository and aud (audience) claims:

  • aud must equal ${PUBLIC_BASE_URL}/p/<projectSlug>, which binds the token to a specific project on this instance.
  • repository must match a Repo row registered under that project (by owner/name).

There are no shared secrets to configure or rotate. A leaked token is useless after ~6 minutes and cannot be minted from outside the registered repo.

Setup

  1. In the dashboard, go to the project's Repos tab and add the repo by name (owner/name).
  2. Copy the two YAML files shown on the page into .github/workflows/ on the repo's default branch:
    • contribution-check-gate.yml: runs on every PR (pull_request_target) and gates open/reopen.
    • contribution-check-reconcile.yml: runs every 10 minutes; reopens PRs whose authors have since been approved.
  3. Push. The next PR is gated automatically.

The workflow YAMLs come pre-filled with ${PUBLIC_BASE_URL} and the project slug.

Limitations vs the GitHub App

  • Reopen latency: up to 10 minutes (the reconcile cron interval). The App reopens immediately on approval.
  • Collaborator auto-bypass: not available in CI mode. List collaborators explicitly in the project's bypass handles, or extend the workflow to compute isCollaborator via gh api /repos/{}/collaborators/{} and include it in the check-pr request body.
  • PR labels: the workflow needs issues: write permission (already in the YAML). Labels are created lazily by GitHub on first use.

Security note on pull_request_target

The gate workflow uses pull_request_target so it has access to the OIDC token even for PRs from forks. We do not actions/checkout the PR head SHA; only the base branch's workflow file is executed. This avoids the standard pull_request_target injection vector.

How a PR is gated

GitHub repo                  GitHub App                contribution-checker      SQLite
   │                             │                          │                        │
   │  PR opened ─────────────────▶  webhook delivery ──────▶│ decideForPR() ─────────┤
   │                             │                          │                        │
   │                             │◀── close PR + comment + label  ◀──── PENDING ─────┤
   │                                                        │                        │
   │  user opens /p/<slug>, signs in, submits application ──▶ store ─────────────────▶
   │  admin clicks Approve  ────────────────────────────────▶ update + reopen PRs ───▶
   │                             │◀── reopen prior closed PRs of this user ──┤       │

Decision precedence

decideForPR(repo, ghLogin, ghId) checks (in order):

  1. Manual decision for this (project, ghLogin): if APPROVED/DENIED, that wins.
  2. Bypass list: glob-matched against Project.bypassHandles. Match → bypassed.
  3. Repo collaborator check (if Project.bypassCollabs is on): collaborators bypass.
  4. Latest application for (project, user): APPROVED → approved, DENIED + active cooldown → denied, else → pending.

Features

  • GitHub App webhook gates pull_request.opened and pull_request.reopened.
  • Public per-project landing page at /p/<slug> with sign-in-to-apply flow.
  • Schema-builder application form (text/textarea/url/select/checkbox).
  • Reusable form templates (per user).
  • Three-tier project roles: OWNER / ADMIN / REVIEWER.
  • Super-admin allowlist for project creation.
  • Audit log per project.
  • Internal admin-only notes on each application.
  • In-app notifications + SMTP email + outbound webhooks (HMAC-signed, with retries).
  • Rate limiting on the public apply endpoint.
  • Configurable per-project denial cooldown (or permanent).
  • Manual approve/deny list for any GitHub login (no application required).
  • Bypass list for bots (*[bot] etc.) and optional auto-bypass for repo collaborators.
  • Configurable per-project labels for pending, approved, denied.
  • Approval reopens previously-closed PRs by the same user across all linked repos.
  • Revocation modal with optional "close their open PRs" toggle.

Configuration (env vars)

See .env.example. Highlights:

  • DATABASE_URL: SQLite file path (e.g. file:./data/contribution-checker.db)
  • PUBLIC_BASE_URL: your canonical URL, used for emails/webhooks/redirects
  • AUTH_SECRET: Auth.js session secret (openssl rand -base64 32)
  • AUTH_GITHUB_ID/SECRET: OAuth app credentials for human sign-in (separate from the GitHub App)
  • GITHUB_APP_*: GitHub App credentials, populated by /admin/setup
  • SUPER_ADMINS: comma-separated GitHub logins, granted super-admin on first sign-in
  • PROJECT_CREATORS: comma-separated logins, granted project-creation rights on first sign-in
  • SMTP_*: optional, SMTP transport for email notifications
  • VAULT_*: optional, source secrets from HashiCorp Vault (see below)

Secrets via HashiCorp Vault

When VAULT_ADDR is set, the following values can be sourced from Vault instead of plain env vars: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, GITHUB_APP_SLUG, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM.

DATABASE_URL and AUTH_SECRET remain env-only: they're consumed synchronously at module load (Prisma client and Auth.js cookie crypto), and moving them behind Vault would require a wider refactor.

Auth methods

Set VAULT_AUTH_METHOD to one of:

  • token (default): provide VAULT_TOKEN. Easiest for dev and small deployments.
  • approle: provide VAULT_APPROLE_ROLE_ID + VAULT_APPROLE_SECRET_ID (and optionally VAULT_APPROLE_MOUNT, default approle).

VAULT_NAMESPACE is supported for Vault Enterprise namespaces.

Per-secret paths

Each secret is mapped via its own env var: VAULT_<NAME>_PATH. Format:

secret/data/<path>[#<field>]

If #field is omitted, the resolver looks for a key named value.

Example KV v2 layout (one path per logical group):

$ vault kv put secret/cc/github \
    app_id=12345 \
    private_key=@./key.pem \
    webhook_secret=$(openssl rand -hex 32) \
    client_id=Iv1.xxx client_secret=yyy \
    slug=my-contribution-checker

$ vault kv put secret/cc/smtp \
    host=smtp.example.com port=587 \
    user=mailer pass=... \
    from="Contribution Checker <noreply@example.com>"

Then in the env:

VAULT_GITHUB_APP_ID_PATH=secret/data/cc/github#app_id
VAULT_GITHUB_APP_PRIVATE_KEY_PATH=secret/data/cc/github#private_key
VAULT_GITHUB_APP_WEBHOOK_SECRET_PATH=secret/data/cc/github#webhook_secret
VAULT_GITHUB_APP_CLIENT_ID_PATH=secret/data/cc/github#client_id
VAULT_GITHUB_APP_CLIENT_SECRET_PATH=secret/data/cc/github#client_secret
VAULT_GITHUB_APP_SLUG_PATH=secret/data/cc/github#slug
VAULT_SMTP_HOST_PATH=secret/data/cc/smtp#host
VAULT_SMTP_PORT_PATH=secret/data/cc/smtp#port
VAULT_SMTP_USER_PATH=secret/data/cc/smtp#user
VAULT_SMTP_PASS_PATH=secret/data/cc/smtp#password
VAULT_SMTP_FROM_PATH=secret/data/cc/smtp#from

Resolution rules

  • A secret with a VAULT_<NAME>_PATH set is read from Vault on first access and cached for VAULT_CACHE_TTL_SECONDS (default 300).
  • Without a VAULT_<NAME>_PATH, the resolver falls back to process.env[NAME].
  • If Vault is unreachable for a secret whose path is configured, the consumer fails fast: the webhook handler returns 500 rather than proceeding with stale or missing credentials.
  • /admin/vault shows per-secret resolution status (no values displayed).

Sample policy

A minimal Vault policy granting read on the paths above:

path "secret/data/cc/*" {
  capabilities = ["read"]
}

Testing

pnpm test          # vitest
pnpm typecheck     # tsc --noEmit
pnpm build         # next build (also runs prisma generate)

Repository layout

src/
├── app/
│   ├── (root)/                landing page + auth callbacks
│   ├── p/[slug]/              public project landing + apply form
│   ├── dashboard/             auth-gated user/admin dashboard
│   │   ├── projects/[id]/     per-project: overview, applications, decisions, repos, form, settings, audit
│   │   ├── notifications/     in-app notification inbox
│   │   └── templates/         user's saved form templates
│   ├── admin/                 super-admin (allowlist, GitHub App setup)
│   └── api/
│       ├── auth/              Auth.js v5 handlers
│       └── github/            App webhook + post-install + manifest callback
├── lib/
│   ├── applications/          form schema, submission lifecycle, PR decision logic
│   ├── github/                Octokit App, PR actions, collaborator cache, webhook dispatch
│   ├── notifications/         in-app inbox, SMTP email, outbound webhooks
│   ├── auth.ts / auth.config.ts   Auth.js setup (split for edge-safe middleware)
│   ├── authz.ts               role-based authz helpers (requireProjectRole etc.)
│   ├── audit.ts               append-only audit log writer
│   ├── ratelimit.ts           DB-backed sliding-window rate limit
│   └── env.ts                 Zod-validated environment
└── components/
    ├── ui/                    shadcn-style primitives (button, input, card, ...)
    ├── form-renderer.tsx      shared form rendering for builder preview + public apply page
    └── site-header.tsx        global nav with notification bell

License

MIT.

About

Check PRs to see their quality and if they are on your whitelist

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages