Skip to content

Protecting branches

Otterdog supports both mechanisms that GitHub offers to protect branches: repository rulesets and branch protection rules. This guide shows common protections with both mechanisms. To choose between them, see Rulesets or branch protection rules.

Before you start

Answer the following questions before writing a protection:

  • Which branches? Usually the default branch, sometimes also maintenance or release branches.
  • Which workflow? Direct pushes, pull requests with reviews, or pull requests without mandatory review.
  • Which checks? The exact names of the status checks that must pass, see Status Check.
  • Who may bypass the protection? Release automation, maintainers, administrators, or nobody.

Prevent force pushes and deletions

The minimal protection keeps the history of a branch intact without imposing a pull request workflow: users with write access can still push directly, but they cannot rewrite the history or delete the branch.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request: null, // (1)!
          required_status_checks: null,
        },
      ],
    },
  ],
}
  1. Without a pull request requirement and without status checks, the ruleset only blocks force pushes and deletions, which are blocked by default (allows_force_pushes and allows_deletions are false).

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          requires_pull_request: false,
          required_approving_review_count: null, // (1)!
          requires_status_checks: false,
        },
      ],
    },
  ],
}
  1. Must be null when requires_pull_request is disabled, see the validation rules of Branch Protection Rule.

Open in the Playground

Recommendation

Protect at least the default branch of every active repository against force pushes and deletions. This protection does not change the way people work, but prevents the accidental loss of history.

Require pull requests and reviews

Requiring a pull request ensures that every change is visible and checked before it reaches the branch. The following example requires one approval, dismisses approvals when new commits are pushed and requires that the most recent push is approved by someone else than its author.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request+: {
            required_approving_review_count: 1,
            dismisses_stale_reviews: true,
            requires_last_push_approval: true,
          },
          required_status_checks: null,
        },
      ],
    },
  ],
}

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          required_approving_review_count: 1,
          dismisses_stale_reviews: true,
          require_last_push_approval: true,
          requires_status_checks: false,
        },
      ],
    },
  ],
}

Open in the Playground

Organization policy

The number of required approvals is a trade-off between assurance and the capacity of the team:

  • 0 requires a pull request, so that checks run and changes are visible, but no review.
  • 1 requires a second person to look at every change.
  • 2 or more increases assurance, but can block small teams.

The example default configuration requires 2 approvals. Choose a value that the team can sustain, as a requirement that cannot be met tends to be bypassed.

Further options of the pull request requirement:

Need Ruleset (required_pull_request) Branch protection rule
Request a review of the code owners requires_code_owner_review requires_code_owner_reviews
Resolve all review conversations before merging requires_review_thread_resolution requires_conversation_resolution
Restrict the merge methods of pull requests allowed_merge_methods not available

Code owner reviews require a CODEOWNERS file in the repository, see About code owners. The merge methods available for all branches of a repository are configured by the repository settings allow_merge_commit, allow_squash_merge and allow_rebase_merge.

Require status checks

Required status checks ensure that continuous integration succeeded before a change is merged.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request+: {
            required_approving_review_count: 1,
          },
          required_status_checks+: {
            strict: true, // (1)!
            status_checks+: [
              "build",
              "test / unit-tests",
            ],
          },
        },
      ],
    },
  ],
}
  1. Requires the branch of the pull request to be up to date with the target branch before merging.

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          required_approving_review_count: 1,
          requires_status_checks: true,
          requires_strict_status_checks: true, // (1)!
          required_status_checks+: [
            "build",
            "test / unit-tests",
          ],
        },
      ],
    },
  ],
}
  1. Requires the branch of the pull request to be up to date with the target branch before merging.

Open in the Playground

The names of the checks must exactly match the names reported to GitHub, see Status Check for the naming rules of workflow jobs.

Warning

A required check that never reports a status blocks the merge of every pull request. This happens for example when a workflow is skipped because of path or branch filters, see Handling skipped but required checks.

Recommendation

When a check is reported by a GitHub App, use the format <app-slug>:<check-name>, so that only this app can satisfy the check.

Requiring up-to-date branches (strict) guarantees that the checks ran against the latest state of the target branch, at the cost of more rebases and CI runs in busy repositories. A merge queue provides the same guarantee without requiring authors to update their branches. Otterdog supports merge queues for rulesets only, see required_merge_queue in Repository Ruleset.

Require code scanning results and Copilot reviews

Rulesets can also block merges on the results of code scanning, and request a review from Copilot automatically for new pull requests.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request+: {
            required_approving_review_count: 1,
          },
          required_code_scanning: orgs.newCodeScanning() {
            code_scanning_tools+: [
              "CodeQL:high_or_higher:errors", // (1)!
            ],
          },
          copilot_code_review: orgs.newCopilotCodeReview() {
            review_on_push: true, // (2)!
          },
        },
      ],
    },
  ],
}
  1. Blocks the merge if CodeQL reports security alerts of severity high or critical, or alerts of severity error.
  2. Copilot also reviews the new pushes to a pull request, not only its first version.

Open in the Playground

Code scanning has to be set up for the repository, see Repository security features. Copilot only reviews the pull requests of authors that have access to Copilot code review.

Require code quality, coverage and license compliance

Repository rulesets can block merges on the code quality results, the line coverage and the license compliance of the dependencies of a pull request:

orgs.newOrg('my-project', 'my-org') {
  settings+: {
    plan: "enterprise", // (1)!
  },
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request+: {
            required_approving_review_count: 1,
          },
          required_code_quality: orgs.newCodeQuality() {
            severity: "warnings", // (2)!
          },
          required_code_coverage: orgs.newCodeCoverage() {
            minimum_coverage: 80, // (3)!
            max_coverage_drop: 1,
          },
          requires_license_compliance_scanning: true, // (4)!
        },
      ],
    },
  ],
}
  1. Read-only property that reflects the billing plan of the organization. The code quality and code coverage rules require at least GitHub Team, the license compliance rule GitHub Enterprise, validate warns otherwise.
  2. Code quality results of severity warning or error must be resolved before a pull request can be merged.
  3. A pull request is blocked if its line coverage is below 80 %, or drops by more than 1 percentage point compared to the default branch. GitHub does not wait for coverage uploads: make the checks that upload coverage data required status checks. This rule is in public preview on GitHub.
  4. The dependencies added or changed by a pull request must comply with the license policy of the organization.

Open in the Playground

These rules are only supported by repository rulesets, not by organization rulesets.

Restrict commit messages and branch names

Patterns can enforce conventions for the metadata of commits, e.g. their message or the email of their author, and for the names of branches:

orgs.newOrg('my-project', 'my-org') {
  settings+: {
    plan: "enterprise", // (1)!
  },
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('commit-messages') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          required_pull_request: null,
          required_status_checks: null,
          commit_message_pattern: orgs.newPattern() {
            name: "Conventional Commits",
            operator: "regex", // (2)!
            pattern: "^(feat|fix|docs|chore|refactor|test|ci|build|perf|revert)(\\(.+\\))?!?: ",
          },
        },
        orgs.newRepoRuleset('branch-names') {
          include_refs+: [
            "~ALL",
          ],
          exclude_refs+: [
            "~DEFAULT_BRANCH",
          ],
          allows_creations: true, // (3)!
          allows_deletions: true,
          allows_force_pushes: true,
          required_pull_request: null,
          required_status_checks: null,
          branch_name_pattern: orgs.newPattern() {
            name: "Branch prefix",
            operator: "regex",
            pattern: "^(feature|fix|release|dependabot)/", // (4)!
          },
        },
      ],
    },
  ],
}
  1. Read-only property that reflects the billing plan of the organization. Restricting the metadata of commits and the names of branches requires GitHub Enterprise, validate warns otherwise.
  2. The operator is one of starts_with, ends_with, contains or regex, set negate: true to reject the values that match the pattern instead, see Pattern Settings.
  3. The ruleset only restricts the names of branches: creating, deleting and force pushing branches stay allowed.
  4. Branches created by automation, e.g. Dependabot, must match the pattern too.

Open in the Playground

commit_author_email_pattern and committer_email_pattern restrict the emails of the commits in the same way. A branch name pattern is only supported by rulesets with target: "branch".

Restrict the files that can be pushed

A ruleset with target push blocks pushes that add files matching restricted paths or extensions, files whose path is too long or files that are too large, to any branch of the repository. Push rulesets are only available for private and internal repositories.

A push ruleset checks the content of every push, to any ref of the repository and of its fork network: it has no target branch and no pull request to block. GitHub therefore only supports the push rules in a push ruleset, the rules protecting branches, e.g. requires_linear_history, and the rules blocking the merge of pull requests, e.g. required_code_scanning or required_code_quality, have to be defined in a separate ruleset with target branch:

orgs.newOrg('my-project', 'my-org') {
  settings+: {
    plan: "team", // (1)!
  },
  _repositories+:: [
    orgs.newRepo('my-repo') {
      private: true, // (2)!
      allow_forking: false,
      rulesets: [
        orgs.newRepoRuleset('default-branch') { // (3)!
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          requires_linear_history: true,
          required_code_quality: orgs.newCodeQuality() {
            severity: "warnings",
          },
        },
        orgs.newRepoRuleset('restrict-files') {
          target: "push",
          allows_creations: true, // (4)!
          allows_deletions: true,
          allows_force_pushes: true,
          required_pull_request: null,
          required_status_checks: null,
          restricted_file_paths+: [ // (5)!
            ".env",
            "secrets/**",
          ],
          restricted_file_paths_exceptions+: [ // (6)!
            "secrets/README.md",
          ],
          max_file_path_length: 255, // (7)!
          restricted_file_extensions+: [
            "*.exe",
            "*.jar",
          ],
          max_file_size: 10, // (8)!
          max_file_size_exceptions+: [ // (9)!
            "docs/assets/**",
          ],
        },
      ],
    },
  ],
}
  1. Read-only property that reflects the billing plan of the organization. Push rulesets require at least GitHub Team, validate warns otherwise.
  2. Push rulesets require a private or internal repository. The default settings of an organization disable the forking of private repositories.
  3. The rules protecting the default branch and blocking the merge of pull requests into it are defined in a ruleset with target branch, the default target of orgs.newRepoRuleset.
  4. A push ruleset only supports push rules, the branch rules enabled by orgs.newRepoRuleset have to be disabled.
  5. The paths of the files that can not be pushed, using fnmatch syntax.
  6. The paths of the files that can be pushed even if they match restricted_file_paths.
  7. The maximum number of characters of the path of a file, between 1 and 32767.
  8. The maximum size of a file in megabytes, between 1 and 100, files stored with Git LFS are not limited.
  9. The paths of the files that can exceed max_file_size.

Open in the Playground

validate reports the rules that a push ruleset does not support: an error for the rules blocking the merge of pull requests and the patterns restricting commit metadata or names of refs, and a warning for the branch rules that orgs.newRepoRuleset enables by default, e.g. allows_force_pushes or required_pull_request, to keep the configurations defining push rulesets before otterdog supported push rules valid.

Protect maintenance and release branches

Maintenance branches often need the same protection as the default branch. A ruleset can protect several branch patterns at once, while a branch protection rule matches a single pattern.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('protected-branches') {
          include_refs+: [
            "~DEFAULT_BRANCH",
            "refs/heads/release/*",
          ],
          allows_creations: true, // (1)!
          required_pull_request+: {
            required_approving_review_count: 1,
          },
          required_status_checks: null,
        },
      ],
    },
  ],
}
  1. By default, only bypass actors can create branches that match a ruleset. Allow creations if contributors should be able to create new release branches.

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          required_approving_review_count: 1,
          requires_status_checks: false,
        },
        orgs.newBranchProtectionRule('release/*') {
          required_approving_review_count: 1,
          requires_status_checks: false,
        },
      ],
    },
  ],
}

Open in the Playground

Patterns follow the fnmatch syntax with the File::FNM_PATHNAME flag: * does not match /. Use release/**/* to match nested names like release/1.x/fixes, see About branch protection rules.

Note

Only one branch protection rule applies to a branch: a rule that names the branch exactly takes precedence over patterns, and among patterns the rule created first wins. All matching rulesets apply together, and the most restrictive version of each rule is enforced. Restricting the creation of matching branches is done with allows_creations for rulesets, and with restricts_pushes and blocks_creations for branch protection rules.

Allow automation to push

Release automation sometimes needs to push to a protected branch, e.g. to commit a version change. Grant such an exception to the narrowest possible actor.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      rulesets: [
        orgs.newRepoRuleset('default-branch') {
          include_refs+: [
            "~DEFAULT_BRANCH",
          ],
          bypass_actors+: [
            "my-release-app", // (1)!
            "@my-org/release-managers:pull_request", // (2)!
          ],
          required_pull_request+: {
            required_approving_review_count: 1,
          },
          required_status_checks: null,
        },
      ],
    },
  ],
}
  1. A GitHub App, referenced by its slug, can always bypass the ruleset.
  2. Members of this team can only bypass the ruleset when merging a pull request, not by pushing directly.

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-repo') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          required_approving_review_count: 1,
          requires_status_checks: false,
          bypass_pull_request_allowances+: [
            "my-release-app",
          ],
        },
      ],
    },
  ],
}

Open in the Playground

See Bypass Actor and Actor for the supported formats. The two mechanisms differ:

  • a ruleset bypass actor bypasses all rules of the ruleset, either always or only when merging a pull request
  • a branch protection rule bypass allowance only lifts the pull request requirement, required status checks still apply

Recommendation

Prefer a GitHub App or a dedicated team over individual user accounts as bypass actors: their permissions can be reviewed and revoked without changing the protection, and they do not depend on a single person.

Warning

Avoid repository roles such as #Write as bypass actors of a ruleset that requires pull requests: every user with this role could then merge without review, which makes the requirement optional.

Read-only branches and mirrors

Some branches must not receive changes at all, e.g. a branch that is mirrored from another system or a frozen maintenance branch.

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-mirror') {
      rulesets: [
        orgs.newRepoRuleset('read-only') {
          include_refs+: [
            "~ALL",
          ],
          allows_updates: false,
          bypass_actors+: [
            "my-mirror-app", // (1)!
          ],
          required_pull_request: null,
          required_status_checks: null,
        },
      ],
    },
  ],
}
  1. Only the app that synchronizes the mirror can push.

Open in the Playground

orgs.newOrg('my-project', 'my-org') {
  _repositories+:: [
    orgs.newRepo('my-mirror') {
      branch_protection_rules: [
        orgs.newBranchProtectionRule('main') {
          requires_pull_request: false,
          required_approving_review_count: null,
          requires_status_checks: false,
          restricts_pushes: true,
          push_restrictions+: [
            "my-mirror-app", // (1)!
          ],
        },
      ],
    },
  ],
}
  1. Only the app that synchronizes the mirror can push.

Open in the Playground

A branch protection rule can also lock a branch completely with lock_branch, see Branch Protection Rule. Restricting who can push to a branch is only available for repositories owned by an organization, see About protected branches.

Administrators

The two mechanisms treat administrators differently:

  • a branch protection rule does not apply to repository administrators and roles with the "bypass branch protections" permission, unless is_admin_enforced is enabled
  • a ruleset applies to everybody, except the actors listed in bypass_actors, e.g. #RepositoryAdmin or #OrganizationAdmin

Organization policy

Exempting administrators allows fixing a broken branch quickly, while enforcing the protection for them guarantees that every change follows the same process. If administrators need an exemption, make it explicit as a bypass actor of a ruleset, which is visible to everybody with read access to the repository.

Common pitfalls

  • Default branch renamed: a branch protection rule matches a branch name or pattern and must be updated when the default branch is renamed, while ~DEFAULT_BRANCH in a ruleset always follows the default branch.
  • Ignored settings: when requires_pull_request or requires_status_checks is disabled in a branch protection rule, the related settings are ignored, and validate reports them.
  • Archived repositories: GitHub does not allow changes to archived repositories, otterdog ignores their branch protection rules.
  • Protections defined outside otterdog: as otterdog manages all branch protection rules and rulesets of a repository, protections created in the GitHub web interface are removed when the configuration is applied with deletions enabled, see Rulesets or branch protection rules.