Create GitHub repositories with safe defaults applied automatically. Replaces the five-minute post-creation settings checklist with a single command.
gh-safe-repo create <owner/repo>
Branch protection, immutable tags, Dependabot, restricted Actions permissions, secret scanning with push protection, and disabled wiki and projects — all configured before you write your first line of code.
gh-safe-repo is undergoing heavy development. It works well for the use-case of creating a new repo with secure defaults. I am working on polishing the CLI options to best align with users expectations. Expect breaking changes until we get to a point where I'm doing releases, and have CI/CD nailed down. ✌️
- Why
- What It Changes
- Requirements
- Installation
- Quick Start
- CLI Reference
- Dry Run / Plan Output
- Fix Mode (Audit Existing Repos)
- Mirroring Repos (
--from) - Creating a Repo from a Local Directory (
--local) - Pre-flight Security Scanner
- Configuration
- GitHub Plan Limitations
- How It Works
- Development
GitHub's default repository settings are optimised for discoverability and flexibility, not security. Every new repo ships with:
- Wiki and Projects enabled (attack surface, even if unused)
- Merge commits allowed (messy history, but not the main concern)
- No branch protection (anyone with write access can push directly to
main) - No Dependabot alerts
- GitHub Actions with write permissions to the repository
- Actions allowed to approve pull requests
Fixing all of this manually takes minutes per repo and is easy to forget. gh-safe-repo applies an opinionated but practical set of defaults in one shot, with a plan preview so you know exactly what will change before anything does.
| Setting | GitHub default | Safe default | Notes |
|---|---|---|---|
| Visibility | Public | Private | Pass --public to override |
| Wiki | Enabled | Disabled | |
| Projects | Enabled | Disabled | |
| Issues | Enabled | Enabled | |
| Delete branch on merge | Off | Off | Set to true in config for auto-cleanup |
| Allow merge commits | On | On | Set to false in config for squash-only |
| Allow squash merge | On | On | |
| Allow rebase merge | On | On |
| Setting | GitHub default | Safe default |
|---|---|---|
| Allowed actions | All | Selected (GitHub-owned + verified creators; customisable) |
| Default workflow permissions | Read/write | Read-only |
| Actions can approve PRs | Yes | No |
| Require SHA pinning | No | Yes (workflows must pin actions to a commit SHA, not a mutable tag) |
| Fork PR approval policy | First-time contributors new to GitHub | All external contributors — require approval before fork PR workflows run CI. Options: brand-new GitHub accounts only (GitHub default), first-time repo contributors, or all fork PRs (safest) |
| Rule | Value |
|---|---|
| Require pull request before merge | Yes |
| Required approving reviews | 1 |
| Dismiss stale reviews on push | Yes |
| Require conversation resolution | Yes |
| Allow force pushes | No |
| Allow branch deletion | No |
| Enforce on admins | No (allows owner tooling to push) |
Tag protection creates a GitHub Ruleset targeting all tags (* by default, configurable via protected_tags). The following rules are enforced:
| Ruleset rule | Enforced? | Notes |
|---|---|---|
| Restrict creations | No | |
| Restrict updates | Yes | Prevents rewriting / force-pushing tags |
| Restrict deletions | Yes | Prevents git push --delete of tags |
| Require linear history | No | |
| Require deployments to succeed | No | |
| Require signed commits | No | |
| Require status checks to pass | No | |
| Block force pushes | No |
Repository admins are on the bypass list (consistent with the branch protection enforce_admins = false default). Only works on public repos or paid GitHub plans (same restriction as branch protection). Free-plan private repos will see this skipped in the plan output.
| Feature | Behaviour |
|---|---|
| Dependabot alerts | Enabled (public repos / paid plans) |
| Dependabot security updates | Enabled (auto-opens PRs for vulnerable deps) |
| Secret scanning | Automatic on public repos; enabled on private paid plans |
| Push protection | Enabled (blocks commits containing supported secrets) |
| Private vulnerability reporting | Enabled (lets security researchers report privately) |
| Dependency graph | Automatic on public repos; no REST API for private (UI only) |
- Python 3.8+
ghCLI installed and authenticated (gh auth login), orGITHUB_TOKENset in your environmentuvfor installation from source (recommended)truffleHogv3 (optional — used by the pre-flight scanner; auto-detected from PATH, or run via podman/docker; falls back to regex if neither is available)
git clone https://github.com/your-username/gh-safe-repo
cd gh-safe-repo
uv tool install .This installs gh-safe-repo into uv's tool environment and adds it to your PATH.
git clone https://github.com/your-username/gh-safe-repo
cd gh-safe-repo
uv sync # creates .venv
./gh-safe-repo create <owner/repo>gh-safe-repo --help# Create a private repo with all safe defaults
gh-safe-repo create <owner/repo>
# Preview what would happen — no changes made
gh-safe-repo create <owner/repo> --dry-run
# Create a public repo (branch protection + security scanning applied)
gh-safe-repo create <owner/repo> --public
# Mirror an existing repo into a new private repo (with pre-flight scan)
gh-safe-repo create <owner/repo> --from <owner/source>
# Mirror a private repo to a new public repo (with pre-flight scan)
gh-safe-repo create <owner/pub> --from <owner/priv> --public
# Create a repo from a local directory (with pre-flight scan)
gh-safe-repo create <owner/repo> --local ~/projects/myapp
# Same, but make it public (branch protection applied before push)
gh-safe-repo create <owner/repo> --local ~/projects/myapp --public
# Audit an existing repo and apply any missing safe defaults
gh-safe-repo fix <owner/repo>
# Audit without making changes
gh-safe-repo fix <owner/repo> --dry-run
# Apply fixes without confirmation prompt (scripting/batch use)
gh-safe-repo fix <owner/repo> --yes
# Scan a local repo for secrets before pushing anywhere
gh-safe-repo scan .
gh-safe-repo scan ~/projects/myappgh-safe-repo create <owner/repo> [OPTIONS]
gh-safe-repo fix <owner/repo> [OPTIONS]
gh-safe-repo scan <path> [OPTIONS]
All commands that interact with GitHub require the owner/repo format (e.g. myuser/my-repo). For create, the owner is validated against your authenticated GitHub account to prevent mistakes on multi-account systems. For fix, admin permissions on the target repo are required instead, allowing you to fix repos owned by organizations or other accounts where you have admin access.
| Option | Description |
|---|---|
--public |
Create as a public repo (default: private) |
--local PATH |
Push code from a local directory into the new repo. Runs pre-flight scan first. Mutually exclusive with --from. |
--from OWNER/REPO |
Mirror code from an existing repo into the new repo. Runs pre-flight scan. Mutually exclusive with --local. |
--yes / -y |
Skip confirmation prompt and apply immediately (for scripting/batch use) |
--dry-run |
Print the plan without making any changes |
--json |
Emit the plan as JSON to stdout instead of the ANSI table |
--config [PATH] |
Path to config file; bare --config uses built-in defaults only |
--debug |
Print every API call and response |
| Option | Description |
|---|---|
--yes / -y |
Skip confirmation prompt and apply immediately (for scripting/batch use) |
--dry-run |
Show settings diff without applying changes |
--json |
Emit the plan as JSON to stdout instead of the ANSI table |
--config [PATH] |
Path to config file; bare --config uses built-in defaults only |
--debug |
Print every API call and response, plus resolved repo identity (id, full name, owner type) |
| Option | Description |
|---|---|
--config [PATH] |
Path to config file; bare --config uses built-in defaults only |
--debug |
Show scanner details |
Exit code is 0 if no critical findings, 1 if criticals are found.
--dry-run shows exactly what gh-safe-repo would do, without making any changes or API calls. Use it before running for real. Combine with --json for machine-readable plan output:
gh-safe-repo create <owner/repo> --dry-run --json
gh-safe-repo fix <owner/repo> --dry-run --jsonWhen --json is active, the plan is written to stdout as a JSON object and all other messages (progress, warnings, the "Dry run" footer) go to stderr, so the output is clean for piping or scripting.
$ gh-safe-repo create <owner/repo> --dry-run
Plan for my-project (private)
Category Action Setting Value
──────────────────────────────────────────────────────────────────
Repository ADD repository my-project (private)
Repository ADD has_wiki false
Repository ADD has_projects false
Actions ADD default_workflow_permissions read
Actions ADD can_approve_pull_request_reviews false
Branch Protection SKIP branch_protection Not available for private repos on free plan
Security SKIP dependabot_alerts Not available for private repos on free plan
1 setting skipped (GitHub plan limitation).
Dry run — no changes made.
Action colours:
| Action | Meaning |
|---|---|
ADD (green) |
New setting being applied |
UPDATE (yellow) |
Existing setting being changed (audit mode) |
DELETE (red) |
Setting being removed |
SKIP (dim) |
No action needed — already at the desired value, or feature unavailable on your plan/visibility combination |
JSON output (--json):
{
"changes": [
{ "type": "add", "category": "repository", "key": "has_wiki", "old": null, "new": false, "reason": null },
{ "type": "skip", "category": "branch_protection", "key": "branch_protection", "old": null, "new": null, "reason": "Not available for private repos on free plan" }
],
"summary": { "add": 5, "skip": 2 }
}summary only includes types that are present in the plan. Consumers should use .get("delete", 0) etc. rather than assuming all four keys are present.
fix compares an existing repo's current settings against the safe defaults and applies any corrections. No secret scanning — fix is purely about repo settings.
# See what's out of compliance
gh-safe-repo fix <owner/repo> --dry-run
# Apply missing safe defaults
gh-safe-repo fix <owner/repo>
# Apply without confirmation prompt (scripting/batch use)
gh-safe-repo fix <owner/repo> --yesFix mode:
- Fetches the current value of every setting via the GitHub API
- Compares against desired safe defaults
- Shows a plan table with
UPDATEfor changed settings andSKIPfor settings already at the desired value (no-op detection — it never makes API calls that would change nothing) - Prompts for confirmation before applying (skip with
--yes)
Only real changes are applied — settings already at the desired value are shown as SKIP and generate no API calls.
--from mirrors an existing repo into a new one with safe defaults. It works for both private and public destinations:
# Mirror into a new private repo (default)
gh-safe-repo create <owner/repo> --from <owner/source>
# Mirror a private repo to a new public repo (riskiest operation — scanned thoroughly)
gh-safe-repo create <owner/pub> --from <owner/priv> --publicWhat happens, in order:
- The source repo is cloned locally (full clone, no
--depth, so truffleHog can walk the full commit history) - The pre-flight security scanner runs on the local clone
- You review findings and confirm (or abort)
- A new repo is created (private by default, or public with
--public) - Actions permissions and security settings are applied (Dependabot, secret scanning, push protection)
- The full history is mirrored:
git clone --mirror+git push --mirror - Branch and tag protection are applied (after code push, so the target branch exists)
If the scan reveals a problem and you abort, no code is ever copied to GitHub.
Note:
--fromusesowner/repoformat for both the source and destination.
--local PATH is the local-to-GitHub counterpart to --from. It creates a new GitHub repo and pushes code from a directory on your machine.
gh-safe-repo create <owner/repo> --local ~/projects/myapp
gh-safe-repo create <owner/repo> --local ~/projects/myapp --publicWhat happens, in order:
- The pre-flight security scanner runs on the local directory directly (no clone needed)
- You review findings and confirm (or abort)
- A new repo is created, and actions permissions and security settings are applied
- Code is pushed:
- If
PATHis a git repo: the full history is cloned locally and pushed withpush --all --tags(all branches and tags) - If
PATHis a plain directory: files are staged in a fresh repo and pushed as an initial commit - If
PATHis an empty directory: nothing is pushed (silently skipped)
- If
- Branch and tag protection are applied (after code push, so the target branch exists)
- If
PATHis a git repo,originis added to the original local repo pointing at the new GitHub URL, and the current branch's upstream tracking is configured — sogit pushandgit pullwork immediately without extra setup.
Both --local and --from work for private and public repos. They are mutually exclusive.
When PATH is a git repo, the local default branch (via git -C PATH symbolic-ref HEAD) is used to target branch protection rules, so protection lands on the right branch even if it isn't main.
Tip: Run
gh-safe-repo scan PATHfirst if you want to inspect findings without creating anything.
The scanner runs locally and never sends code to GitHub. Use it standalone before any push, or it runs automatically as part of the --from and --local workflows.
# Scan the current directory
gh-safe-repo scan .
# Scan an explicit path
gh-safe-repo scan ~/projects/myappExit code is 0 if no critical findings, 1 if criticals are found — so it composes cleanly with other commands:
gh-safe-repo scan . && git pushThe full [pre_flight_scan] config applies: banned_strings, max_file_size_mb, trufflehog_mode, etc.
| Category | Severity | Examples |
|---|---|---|
| Hardcoded secrets | Critical | AWS keys (AKIA…), GitHub tokens (ghp_…, github_pat_…), private keys, database URLs |
| Banned strings | Critical | Any literal strings you configure (usernames, internal hostnames, codenames) |
| AI context files | Critical | CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions.md, .cursor/ — may contain internal dev notes; git history may be more sensitive than the current version |
| Email addresses | Warning | Any user@domain.tld pattern in working tree and git history |
| Large files | Warning | Files over the configured size threshold (default: 100 MB) |
| TODO/FIXME comments | Info | # TODO, # FIXME, # HACK, # XXX |
gh-safe-repo automatically picks the best available scanner using a three-step discovery chain:
- truffleHog v3 on PATH — runs
trufflehog --version, verifies it is v3, and uses it. A v2 install or an unrecognised version prints a warning and falls through to step 2. - podman or docker — if no native truffleHog is found, the scanner runs truffleHog in a container (
ghcr.io/trufflesecurity/trufflehog:latest) usingpodman runordocker run, mounting the scan path read-only at the same absolute path so JSON output paths are identical to a native run. - Regex fallback — if neither a native install nor a container runtime is available, a warning is printed and the regex scanner runs instead. It also always runs in addition to truffleHog for emails and TODOs, and catches lone key-ID patterns that truffleHog deliberately skips (truffleHog requires both halves of a credential pair, e.g. AWS Key ID and Secret Access Key, before flagging a finding).
The selected scanner is shown in the "Running pre-flight security scan..." header and in the plan table's SCAN entry, e.g.:
Running pre-flight security scan... (truffleHog v3.93.4)
Running pre-flight security scan... (truffleHog via podman)
Running pre-flight security scan... (regex only — see warning above)
Environment variables respected by the container path: CONTAINER_RUNTIME to override runtime selection (e.g. CONTAINER_RUNTIME=docker), and TRUFFLEHOG_IMAGE to pin a specific image tag.
No manual setup is required. gh-safe-repo detects podman or docker automatically (step 2 above) and runs truffleHog in a container with the correct volume mounts. CONTAINER_RUNTIME and TRUFFLEHOG_IMAGE environment variables are respected.
A shell wrapper (tools/trufflehog) and a Containerfile for building a pinned local image are provided in tools/ for users who want container-based truffleHog available system-wide, or who need an air-gapped image.
Pre-flight scan: my-private-project
CRITICAL my_private_project/config.py:12 AWS Access Key ID
[redacted]
WARNING my_private_project/setup.py:3 Email address
author_email="alice@example.com"
1 critical finding, 1 warning.
Critical findings detected. Continue anyway? [y/N]:
- Critical findings: Default is abort (
N). You must explicitly typeyto continue. - Warnings only: Default is continue (
Y). Press Enter to proceed or typento abort. - No findings: Scan completes silently and the workflow continues.
Secrets are redacted in the output. Email addresses and TODOs show the matching line.
Build-artifact directories (node_modules, __pycache__, .venv, venv, dist, build) are skipped by default to keep scans fast. In git repos, this skip is conditional: before pruning a directory, the scanner runs git ls-files -- <dir> to check whether any files inside are tracked. If they are, the directory is scanned normally.
This means committed node_modules or dist trees — unusual, but they happen — are not silently missed. Uncommitted directories (the normal case) continue to be skipped as before.
A warning is still printed when SKIP_DIRS subdirectories are found in a cloned source repo, since their presence may indicate that more content than expected is committed.
Two config keys let you suppress known-safe findings without disabling entire check categories.
scan_exclude_paths — skip files or directories entirely. Values are newline/comma-separated regex patterns matched against the relative file path. A matching file is excluded from every check: secrets, emails, TODOs, large files, and AI context file detection. The same patterns are also passed to truffleHog via --exclude-paths, so coverage is consistent regardless of which scanner engine is active.
[pre_flight_scan]
# Exclude the GitHub API spec (example tokens) and all test fixtures
scan_exclude_paths = docs/api\.github\.com\.json
tests/fixtures/exclude_emails — suppress email findings for specific addresses or entire domains. Values are newline/comma-separated, case-insensitive. Entries starting with @ match all emails at that domain; otherwise the entry must match the full address exactly. Applies to both working-tree and git history findings.
[pre_flight_scan]
# Suppress bot addresses and placeholder domains
exclude_emails = action@github.com, noreply@github.com, @example.com[pre_flight_scan]
scan_for_secrets = true
scan_for_emails = true
scan_for_todos = true
max_file_size_mb = 100
# Scan git history for email addresses (requires scan_for_emails = true)
# scan_email_history = true
# Scanner selection: auto | native | docker | off
# auto — try native truffleHog, fall back to container (podman/docker), then regex (default)
# native — native truffleHog only; no container fallback
# docker — container only; skip native PATH check
# off — regex scanner only, no truffleHog attempt
# trufflehog_mode = auto
# Flag AI context files (CLAUDE.md, AGENTS.md, .cursorrules, etc.) as critical findings.
# Their git history may contain more sensitive content than the current version.
# warn_ai_context_files = true
# Literal strings to flag as critical findings (case-insensitive).
# Comma-separated or one per line (continuation lines must be indented).
# banned_strings = secret
# password
# credential
# Exclude files/directories from all scan checks (regex patterns, comma/newline separated).
# The same patterns are passed to truffleHog via --exclude-paths.
# scan_exclude_paths = docs/api\.github\.com\.json
# tests/fixtures/
# Suppress email findings for specific addresses or entire domains (case-insensitive).
# Entries starting with @ match all emails at that domain; otherwise exact address match.
# exclude_emails = action@github.com, noreply@github.com, @example.comWhen banned strings or AI context files are found the scanner prints a ready-to-run git filter-repo command to remove them from the source repo's history before re-running.
gh-safe-repo looks for configuration in this order (first match wins):
--config PATH— explicit override./gh-safe-repo.ini— current working directory$XDG_CONFIG_HOME/gh-safe-repo/gh-safe-repo.ini— defaults to~/.configwhen$XDG_CONFIG_HOMEis unset
Bare --config (no path) skips file lookup entirely and uses built-in defaults only.
All values have safe defaults — no config file is required to get started.
A fully-annotated example config is included in the repository as gh-safe-repo.ini.example. Copy it to get started:
# User-level config (XDG)
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/gh-safe-repo"
cp gh-safe-repo.ini.example "${XDG_CONFIG_HOME:-$HOME/.config}/gh-safe-repo/gh-safe-repo.ini"
# Or project-level config (current directory)
cp gh-safe-repo.ini.example ./gh-safe-repo.ini[repo]
# Whether new repos are private by default
private = true
# Disable features that create clutter if unused
has_wiki = false
has_projects = false
has_issues = true
# Auto-delete head branches after merge (default: off, matching GitHub)
delete_branch_on_merge = false
# Merge strategies (all enabled by default, matching GitHub)
# Set allow_merge_commit = false for squash-only workflows
allow_squash_merge = true
allow_merge_commit = true
allow_rebase_merge = true
# Do not initialize with a README — keeps the remote empty so pushing is seamless
auto_init = false
[actions]
# Which actions are allowed to run: all | local_only | selected
allowed_actions = selected
# When allowed_actions = selected, control which external actions are permitted:
github_owned_allowed = true # actions maintained by GitHub (e.g. actions/checkout)
verified_allowed = true # actions from Marketplace verified creators
# patterns_allowed = myorg/* # comma-separated allowlist (wildcards OK)
# Principle of least privilege: read-only by default
# Options: read | write
default_workflow_permissions = read
# Prevent Actions from self-approving pull requests
can_approve_pull_request_reviews = false
# Require workflows to pin actions to a specific commit SHA instead of a mutable tag
sha_pinning_required = true
[branch_protection]
# Applied to public repos on any plan, and private repos on paid plans.
# Branch to protect
protected_branch = main
# Require a pull request before merging
require_pull_request = true
# Number of approvals required
required_approving_reviews = 1
# Dismiss existing approvals when new commits are pushed
dismiss_stale_reviews = true
# Require all review comments to be resolved before merging
require_conversation_resolution = true
# Do not enforce rules on administrators
# false = repo owner can still push directly (needed for --from mirror workflow)
enforce_admins = false
# Block force-pushes
allow_force_pushes = false
# Block branch deletion
allow_deletions = false
# Use the Rulesets API instead of classic branch protection
# Same rules, but supports bypass actors and is the modern GitHub API
# use_rulesets = false
[tag_protection]
# Immutable tags via Rulesets API.
# Only works on public repos or paid GitHub plans (same restriction as branch protection).
# Glob pattern(s) for tags to protect — comma-separated.
protected_tags = *
# Prevent deletion of matching tags (git tag -d / git push --delete)
prevent_tag_deletion = true
# Prevent rewriting matching tags (git tag -f / force-push)
prevent_tag_update = true
[security]
# Enable Dependabot vulnerability alerts
enable_dependabot_alerts = true
# Auto-open PRs to fix vulnerable dependencies
enable_dependabot_security_updates = true
# Let security researchers report vulnerabilities privately
enable_private_vulnerability_reporting = true
# Block commits that contain supported secrets
enable_secret_scanning_push_protection = true
# Note: The following features have no REST API and must be configured via UI or dependabot.yml:
# - Grouped security updates: use dependabot.yml groups with applies-to: security-updates
# - Automatic dependency submission: enable via repository settings UI
# - Dependency graph: automatic for public repos; enable via UI for private repos
[pre_flight_scan]
scan_for_secrets = true
scan_for_emails = true
scan_for_todos = true
# Flag files larger than this threshold
max_file_size_mb = 100
# Scan git history for email addresses (requires scan_for_emails = true)
# scan_email_history = true
# Scanner selection: auto | native | docker | off
# auto = try native truffleHog, fall back to container (podman/docker), then regex
# native = native PATH only
# docker = container only
# off = regex only
# trufflehog_mode = auto
# Flag AI context files (CLAUDE.md, AGENTS.md, .cursorrules, etc.) as critical findings.
# warn_ai_context_files = true
# Literal strings to flag as critical findings (case-insensitive).
# Comma-separated, or one per line with continuation indentation.
# banned_strings = secret
# password
# credential
# Exclude files/directories from all scan checks (regex patterns, comma/newline separated).
# Passed to truffleHog via --exclude-paths as well as applied to the regex walk.
# scan_exclude_paths = docs/api\.github\.com\.json
# tests/fixtures/
# Suppress email findings for specific addresses or entire domains (case-insensitive).
# Entries starting with @ match all emails at that domain; otherwise exact address match.
# exclude_emails = action@github.com, noreply@github.com, @example.comSome features are only available depending on repo visibility and your GitHub plan.
| Feature | Free + Public | Free + Private | Pro/Team + Private |
|---|---|---|---|
| Branch protection / Rulesets | Yes | No | Yes |
| Tag protection (Rulesets) | Yes | No | Yes |
| Dependabot alerts | Yes | No | Yes |
| Dependabot security updates | Yes | No | Yes |
| Secret scanning | Auto | No | Yes |
| Push protection | Yes | No | Yes |
| Private vulnerability reporting | Yes | Yes | Yes |
| Dependency graph | Auto | No | Yes |
gh-safe-repo detects your plan level and repo visibility at runtime. Unavailable features appear as SKIP in the plan output with a clear reason — the tool never fails silently.
gh-safe-repo create <owner/repo>
│
├─ Parse owner/repo, validate owner matches authenticated user (create only)
├─ Load config (./gh-safe-repo.ini or $XDG_CONFIG_HOME/gh-safe-repo/gh-safe-repo.ini)
├─ Apply CLI flag overrides (--public, etc.)
├─ Authenticate via gh CLI or GITHUB_TOKEN
├─ GET /user → owner login + plan level (single cached call)
│
├─ Build plan (each plugin compares desired vs. current state)
│ ├─ RepositoryPlugin → repo creation + basic settings
│ ├─ ActionsPlugin → allowed actions, workflow permissions, SHA pinning
│ ├─ BranchProtectionPlugin → classic or Rulesets API
│ ├─ SecurityPlugin → Dependabot, secret scanning, push protection, private vuln reporting
│ └─ TagProtectionPlugin → immutable tags via Rulesets API
│
├─ Print plan table
│
└─ Apply (unless --dry-run)
├─ POST /user/repos
├─ PATCH /repos/{owner}/{repo} (settings)
├─ PUT /repos/{owner}/{repo}/actions/permissions/workflow
├─ PUT /repos/{owner}/{repo}/branches/main/protection
│ or POST /repos/{owner}/{repo}/rulesets (if use_rulesets = true)
├─ PUT /repos/{owner}/{repo}/vulnerability-alerts
├─ PUT /repos/{owner}/{repo}/automated-security-fixes
├─ PUT /repos/{owner}/{repo}/private-vulnerability-reporting
├─ PATCH /repos/{owner}/{repo} (security_and_analysis: push protection)
├─ POST /repos/{owner}/{repo}/rulesets (tag protection ruleset)
├─ git clone --mirror + git push --mirror (if --from)
└─ git clone <local> + git push --all --tags (if --local, git repo)
or git init + add -A + commit + push (if --local, plain dir)
Each category of settings is a self-contained plugin class (gh_safe_repo/plugins/). Every plugin:
- Fetches current state from the GitHub API
- Compares against desired state from config
- Returns a
Plan(list ofChangeobjects: ADD / UPDATE / DELETE / SKIP) - Applies only real changes — no API calls for no-ops
This means audit mode and create mode use the same plan/apply path. The only difference is whether current state is fetched from an existing repo or assumed to be GitHub defaults.
gh auth token— preferred; uses whatevergh auth loginset upGITHUB_TOKENenvironment variable — CI/CD fallback- Error if neither is available
Tokens are passed to child gh api processes as GH_TOKEN in the subprocess environment and are never logged.
All GitHub API calls go through gh api via subprocess. This keeps authentication entirely in the gh CLI — no token management code, no OAuth flow, no PyGithub version pinning. JSON request bodies are passed via --input - (stdin), not --field flags.
# Clone and set up
git clone https://github.com/your-username/gh-safe-repo
cd gh-safe-repo
uv sync # creates .venv, installs pytest
# Run tests
uv run pytest tests/ -v
# Run the tool directly (without installing)
./gh-safe-repo create <owner/repo> --dry-run
# Install globally (picks up the current source)
uv tool install .See tests/README.md for test file descriptions, mocking conventions, and how to add new tests.
gh-safe-repo/
├── gh-safe-repo # Thin launcher (entry point for direct use)
├── gh_safe_repo/ # Package — see gh_safe_repo/README.md for internals
│ ├── cli.py # Subparser dispatch (create, fix, scan)
│ ├── commands/ # Subcommand implementations
│ │ ├── _common.py # Shared helpers, CLIContext, plan formatting
│ │ ├── create.py # create subcommand
│ │ ├── fix.py # fix subcommand
│ │ └── scan.py # scan subcommand
│ └── plugins/ # Settings plugins (one per category)
├── pyproject.toml # Build config, entry points
├── gh-safe-repo.ini.example # Fully annotated example config
└── tests/
See gh_safe_repo/README.md for the module map, plugin architecture, and a guide to adding new settings.
There are no runtime dependencies. Everything uses the Python standard library (argparse, configparser, subprocess, json, re). Do not add third-party packages without discussion.
pytest is the only dev dependency, declared as a UV-native [dependency-groups] entry in pyproject.toml.
These projects were studied during design and influenced the architecture of gh-safe-repo. They are distinct tools with different scope and user models — see docs/LEARNINGS.md for detailed technical notes on how patterns were adapted.
-
github/safe-settings — Org-level GitHub App (Node.js/Probot) that enforces repository settings from a central config. Source of the plugin architecture pattern (one class per setting category, fetch → diff → apply) and the
mergeDeepcomparison approach. -
repository-settings/app — Simpler per-repo variant of safe-settings, also Node.js/Probot. Provided a cleaner reference for the
Diffablebase plugin pattern. -
nicholasgasior/gh-repo-settings — CLI extension written in Go with a
plan/applyworkflow. Primary inspiration for thegh apisubprocess wrapper pattern and the dry-run plan output design.