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,
},
],
},
],
}
- Without a pull request requirement and without status checks, the ruleset only blocks force pushes and
deletions, which are blocked by default (
allows_force_pushesandallows_deletionsarefalse).
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,
},
],
},
],
}
- Must be
nullwhenrequires_pull_requestis disabled, see the validation rules of Branch Protection Rule.
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,
},
],
},
],
}
Organization policy
The number of required approvals is a trade-off between assurance and the capacity of the team:
0requires a pull request, so that checks run and changes are visible, but no review.1requires a second person to look at every change.2or 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",
],
},
},
],
},
],
}
- Requires the branch of the pull request to be up to date with the target branch before merging.
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",
],
},
],
},
],
}
- Requires the branch of the pull request to be up to date with the target branch before merging.
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)!
},
},
],
},
],
}
- Blocks the merge if CodeQL reports security alerts of severity high or critical, or alerts of severity error.
- Copilot also reviews the new pushes to a pull request, not only its first version.
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)!
},
],
},
],
}
- 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,
validatewarns otherwise. - Code quality results of severity warning or error must be resolved before a pull request can be merged.
- 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.
- The dependencies added or changed by a pull request must comply with the license policy of the organization.
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)!
},
},
],
},
],
}
- Read-only property that reflects the billing plan of the organization. Restricting the metadata of commits and
the names of branches requires GitHub Enterprise,
validatewarns otherwise. - The operator is one of
starts_with,ends_with,containsorregex, setnegate: trueto reject the values that match the pattern instead, see Pattern Settings. - The ruleset only restricts the names of branches: creating, deleting and force pushing branches stay allowed.
- Branches created by automation, e.g. Dependabot, must match the pattern too.
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/**",
],
},
],
},
],
}
- Read-only property that reflects the billing plan of the organization. Push rulesets require at least GitHub
Team,
validatewarns otherwise. - Push rulesets require a private or internal repository. The default settings of an organization disable the forking of private repositories.
- 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 oforgs.newRepoRuleset. - A push ruleset only supports push rules, the branch rules enabled by
orgs.newRepoRulesethave to be disabled. - The paths of the files that can not be pushed, using
fnmatchsyntax. - The paths of the files that can be pushed even if they match
restricted_file_paths. - The maximum number of characters of the path of a file, between 1 and 32767.
- The maximum size of a file in megabytes, between 1 and 100, files stored with Git LFS are not limited.
- The paths of the files that can exceed
max_file_size.
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,
},
],
},
],
}
- By default, only bypass actors can create branches that match a ruleset. Allow creations if contributors should be able to create new release branches.
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,
},
],
},
],
}
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,
},
],
},
],
}
- A GitHub App, referenced by its slug, can always bypass the ruleset.
- Members of this team can only bypass the ruleset when merging a pull request, not by pushing directly.
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,
},
],
},
],
}
- Only the app that synchronizes the mirror can push.
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)!
],
},
],
},
],
}
- Only the app that synchronizes the mirror can push.
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_enforcedis enabled - a ruleset applies to everybody, except the actors listed in
bypass_actors, e.g.#RepositoryAdminor#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_BRANCHin a ruleset always follows the default branch. - Ignored settings: when
requires_pull_requestorrequires_status_checksis disabled in a branch protection rule, the related settings are ignored, andvalidatereports 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.