Net NinjasBook a call
All guides

Guide

GitOps best practices for GitHub repositories

A free guide to GitOps guardrails on GitHub: rulesets, independent reviewers, required pull requests and checks, Conventional Commits, and branch names.

OpenGitOps, a CNCF working group, defines GitOps through four principles. The desired state is declarative, versioned and immutable, pulled automatically, and continuously reconciled. In practice, an agent such as Argo CD or Flux watches a Git repository and keeps the running system in line with it. That makes a merge to the default branch a deployment, and the repository's rules the real change control.

I'm starting to see teams adopt Argo CD or Flux and leave the repository itself wide open. The agent applies whatever lands on the default branch, including a direct push at 23:00 with the message "fix". This guide covers the guardrails I set up on GitHub, in the order I set them up: rulesets, pull requests, reviewers, status checks, commit messages, verified commits, branch names, and tags. It's free, it stands on its own, and nothing here needs a paid tool beyond the GitHub plan a team already has.

This is an engineer's reading of GitHub's documentation and the specifications named in each section, checked on 12 November 2026. Plans, rule names, and defaults change often, so I recommend checking GitHub's current documentation before copying a setting. The guide is free to read and share, and there's nothing to buy at the end of it.

Does it apply to you?

If one of these lines describes your team, the rest of this guide applies. If none does, the repository rules are probably already doing their job. The sections below still work as a checklist to compare against.

  • We deploy with Argo CD, Flux, or a Terraform pipeline that applies whatever lands on the default branch.
  • Our default branch still accepts a direct push from at least one person, even if nobody admits to using it.
  • We have more than a handful of repositories, and each one has its own branch protection settings, or none at all.
  • Our reviewers approve a pull request, and the author can still push one more commit and merge it unseen.
  • Our commit history reads like "wip", "fix", and "final final", so nobody can say what changed in a release.
  • We use AI coding agents or dependency bots that open pull requests, and we want a human approval on every change they make.
  • We can't say for certain that the name on a commit belongs to the person who actually made it.

What the repository needs

Git as the control plane

In a GitOps setup, an agent inside the cluster or account pulls the desired state from Git and corrects any drift from it. Argo CD and Flux are the two I see most, and both are graduated CNCF projects. Because the agent pulls, the CI pipeline never needs credentials for the cluster. The pipeline builds and tests, and the agent deploys.

Versioned and immutable means more than "it's in Git". The OpenGitOps maintainers point out that a manifest pointing at a latest tag can't be rolled back, because the same text means a different image tomorrow. I pin every image to a digest or an immutable tag, and every Helm chart and Terraform module to a version. A rollback then becomes a revert commit, and it passes through the same rules as any other change.

Rulesets at the organisation level

GitHub rulesets replace the older branch protection rules, and they layer. When several rulesets target the same branch, GitHub applies all of their rules together, so a new ruleset can make a branch stricter but never looser. Organisation rulesets reached the GitHub Team plan in June 2025, after years on Enterprise only. I write one organisation ruleset that targets the default branch of every repository, so a new repository is protected from its first commit.

Repository rulesets then add what a single repository needs, such as an extra required check or a deployment rule. Where the plan offers Evaluate mode, a new ruleset starts there, and the Rule Insights page records what would have passed or failed without blocking anything. I watch it for a week or two before switching it to Active. The bypass list stays short and named, such as a release bot or one break-glass team, never every administrator. GitHub exports a ruleset as JSON, so I keep that file in a governance repository and review changes to it like code.

Pull requests are the only way in

The first rule in the organisation ruleset is "Require a pull request before merging" on the default branch. I add "Block force pushes" and "Restrict deletions" next to it, so history on the default branch only moves forward. Allowed merge methods narrow to squash, which also satisfies "Require linear history". With squash merges, each commit on the default branch maps to exactly one pull request, so a revert is a single commit.

The same rule covers people with administrator rights. I used to think administrators needed a way around the rules for emergencies. After testing it on my own repositories, I realised the emergency path works better as a pull request with a lighter review. A bypass for one named team in "For pull requests only" mode gives exactly that. The emergency change still lands with a record of who changed what, and why.

A reviewer who isn't the author

Required approvals only mean something when the approver is someone other than the author. GitHub never counts an author's own approval, but a quieter gap remains. Someone approves, then the author pushes one more commit and merges it. "Require approval of the most recent reviewable push" closes that gap, because the last push needs an approval from someone other than the person who pushed it.

I pair it with "Dismiss stale pull request approvals when new commits are pushed" and one required approval as the floor for a small team. A CODEOWNERS file names owners for sensitive paths, such as the environment manifests, the Terraform for production, and the workflow files in .github/. "Require review from Code Owners" then makes their approval mandatory for those paths. Required conversation resolution stops a merge while a reviewer's question is still open. The platform team owns the CODEOWNERS file itself, so nobody can remove themselves from review in the same pull request.

A solo founder doesn't have a second reviewer yet. In that case I keep the pull request rule and every status check, set required approvals to zero, and raise it to one the week a second engineer joins. Coding agents and dependency bots are a different case. When an agent pushes the last commit, a human has to approve it, which is exactly the rule I want for agent-written code.

Status checks a merge can't skip

Required status checks turn CI from advice into a gate. I name each check exactly as the job reports it, and I set its expected source to the GitHub App that runs it, usually GitHub Actions. Pinning the source stops another integration from posting a passing status under the same name. "Require branches to be up to date before merging" is GitHub's default, and it means the checks ran against the code that will actually land.

For a GitOps repository, the checks I require prove that the desired state is valid before an agent applies it. That means rendering every Helm chart and Kustomize overlay, validating the output against the Kubernetes schemas with kubeconform, and running a policy check such as Conftest or the Kyverno CLI. Terraform repositories get terraform fmt, terraform validate, and a plan posted to the pull request. A secrets scan runs everywhere, as a workflow the organisation ruleset requires in every repository.

On a busy repository, up-to-date branches turn into a queue of rebases. GitHub's merge queue fixes that by testing each pull request on top of the ones ahead of it. It's limited to public repositories owned by organisations and to GitHub Enterprise Cloud, so on the Team plan I keep the strict setting and smaller pull requests instead.

Commit messages a machine can read

Conventional Commits 1.0.0 gives every commit header the shape type(scope): description, such as fix(ingress): raise the proxy timeout to 60 s. The specification defines two types: feat maps to a minor release and fix to a patch release in Semantic Versioning. An exclamation mark after the type, or a BREAKING CHANGE footer, marks a major release. Other types, such as docs, ci, chore, and refactor, come from the Angular convention and carry no version meaning on their own. I keep the header to about 50 characters, as Pro Git suggests, and use the body to explain why the change exists.

The payoff is tooling. Tools such as release-please and semantic-release read the history since the last tag, pick the next version, and write the changelog. I highly recommend squash merges here, with the repository's default squash message set to the pull request title. One check on the title then covers the whole history, and a small action such as amannn/action-semantic-pull-request runs it as a required status check. The commits on a feature branch can stay as messy as the work was.

On GitHub Enterprise Cloud, a ruleset can also enforce a commit message pattern, and GitHub's documentation includes a Conventional Commits regex for it. The rule checks every commit pushed to the branches it targets, so I aim it at the default branch only. On a laptop, commitlint in a Git hook catches a bad header before it leaves the machine. A hook is advice, though, and the server-side check is the rule.

Verified commits

Git lets anyone set the author and committer of a commit to any name and email, so a name on a commit proves nothing on its own. GitHub marks a commit Verified when it carries a GPG, SSH, or S/MIME signature that GitHub can check. A signature it can't check shows as Unverified, and an unsigned commit shows no status at all by default. That default is the gap, because a missing badge looks a lot like a quiet pass.

"Require signed commits" in the default branch ruleset closes it, because GitHub then blocks every commit that isn't signed and verified. I pair it with squash merges on purpose. GitHub signs commits made in the web interface with its own key, so the squash commit lands verified whatever happened on the feature branch. Rebase merges are the trap here, since GitHub adds the head branch's commits to the base branch without signature verification.

For people, SSH signing is the shortest path, since Git has supported it from version 2.34 and GitHub accepts an existing authentication key as a signing key. Bots are a separate case. A GitHub App that creates commits through the API, with no custom author or committer, gets them signed and Verified by GitHub, so release bots pass the rule without a private key in CI. GitHub also stores a verification record when it first verifies a commit, so the badge survives a later key rotation or an engineer leaving.

Vigilant mode is the last piece, and it's a setting on each person's account. With it on, GitHub flags every unsigned commit in that person's name as Unverified, so an impersonation stands out in the history. I turn it on for my own account, and I highly recommend it for every engineer with merge rights.

Branch names with a short shelf life

Branch lifetimes matter more than branch names. I keep one long-lived branch, the default branch, and short-lived branches that merge within a day or two. Environments live in folders or separate repositories, never in long-lived branches such as staging and production, because promotion between branches turns into merge conflicts and drift. Argo CD and Flux can both follow a path on the default branch for each environment. I also switch on automatic deletion of head branches, so merged work doesn't linger.

For names, I reuse the Conventional Commits types as prefixes, followed by the issue key and a few words: feat/NET-142-rate-limits or fix/NET-157-ingress-timeout. Lowercase letters, digits, hyphens, and a single slash keep names safe on file systems that ignore case. Linear and Jira both link a branch to its issue when the key appears in the name. On GitHub Enterprise Cloud, a ruleset can enforce the pattern when a branch is created, and on other plans a required check on the pull request does the same job.

Release tags

Tags deserve a ruleset of their own, because a release pipeline or a GitOps agent often deploys by tag. I protect v* tags from updates and deletion, so v1.4.2 always points at the same commit. Release tags follow Semantic Versioning, which lines up with the version Conventional Commits already worked out. Only the release bot creates them, and a signed tag carries the same Verified badge as a signed commit.

Common questions

Do rulesets replace branch protection rules?
They can, and both work side by side in the meantime. When a ruleset and a branch protection rule target the same branch, GitHub applies both, and the stricter setting wins. I move to rulesets because they apply across an organisation, anyone with read access can see them, and they export as JSON. The old branch protection rules can stay until the matching ruleset has run for a while.
Does any of this work on GitLab?
The ideas carry over, and the setting names change. GitLab has protected branches, merge request approval settings that block approval by the author and by anyone who added commits, and push rules for commit message and branch name patterns. Some of those sit in GitLab's paid tiers, so the plan matters there too. The GitOps agents themselves don't care which forge holds the repository.
How do we ship an emergency fix at 03:00?
Through a pull request, with a lighter path rather than no path. The on-call team sits on the bypass list in "For pull requests only" mode, so it can merge without the usual approvals while the checks still run. The next morning, a second engineer reviews the merged change and opens a follow-up if needed. Rolling back is a revert commit, which the agent applies like any other change.
Where would you start?
One organisation ruleset on the default branch of every repository. It requires a pull request, an approval of the most recent push, and one status check, and it blocks force pushes and deletions. Conventional Commits on pull request titles and the tag ruleset come next, once the first ruleset has run cleanly for a week. Which repository in your organisation still accepts a direct push to its default branch today?