Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Source code governance: rulesets, tags and CODEOWNERS

By updated

Improve this page

You sign your images, scan your dependencies, generate attestations. But who can change your source code? If any account can push to main or move a release tag, all the security downstream can be bypassed from upstream. This lesson is for developers and repository owners who want no change to reach the main branch without explicit validation. It covers GitHub rulesets and GitLab protected branches, tag protection, the CODEOWNERS file and the documentation of contribution rules.

What you will learn

  • Understand why a repository without rules is the first risk of a CI/CD chain, through the tj-actions attack
  • Protect the main branch with a GitHub ruleset or a GitLab protected branch, then check it
  • Lock release tags, which branch protection does not cover
  • Designate the mandatory reviewers of sensitive areas with CODEOWNERS
  • Place your configuration within SLSA Source and the Scorecard score
  • SOCLE anchoring: this lesson produces the evidence for SOCLE-SRC-GEN-1 (in French) and for the branch protection and review requirements

Why govern source code?

Governing source code means deciding who can change what, and under which validation. Without that decision, the rest of the chain protects code whose origin nobody vouches for. The OWASP Top 10 CI/CD puts this risk first, as CICD-SEC-1, "insufficient flow control mechanisms": an attacker who gets write access pushes their code all the way to production without meeting a single reviewer.

What a repository without rules allows

On GitHub as on GitLab, a freshly created repository enforces almost nothing. Any collaborator with write access can push straight to main, merge a pull request without approval, edit a workflow to read the CI secrets, or move a release tag to another commit. And after an incident, nothing lets you reconstruct who validated what.

A compromised account or a malicious contributor can then inject a backdoor into the code, edit a workflow to steal tokens, tamper with a Dockerfile to trap the image, or swap a dependency for a malicious package. Each of these changes reaches production through the normal path, since nothing stops it.

What the tj-actions attack shows

In March 2025, the GitHub action tj-actions/changed-files, used by more than 23,000 repositories, was compromised (CVE-2025-30066). The attacker held a personal access token stolen from a bot that had write access to the repository. They touched no branch at all: they rewrote the release tags to point at a malicious commit, which printed the pipelines' secrets into their build logs.

The lesson cuts both ways. On the publisher side, branch protection only protects branches: tags need their own rule, and a broadly scoped bot token is a key to the whole house. On the consumer side, only pinning by commit SHA protected projects, which is covered in Pinning actions by SHA. The risk of overly broad tokens is developed in OWASP Top 10 CI/CD.

Which mechanisms should you combine?

Source code governance rests on four complementary mechanisms. The table says what each one brings and what happens when it is missing. The first one is the foundation: without it, the others remain suggestions anyone can ignore.

MechanismRoleWithout it
Branch protection (ruleset)Forbids direct pushes, requires a validated pull request and a green CIAny account changes main
Tag protectionForbids moving or deleting a release tagA published version changes content silently
CODEOWNERSNames who must approve each area of the codeAny collaborator approves
CONTRIBUTING.mdDocuments the rules for contributorsFuzzy rules, honest mistakes

Step 1: protect the main branch

Branch protection is the repository's automatic gatekeeper: it refuses any direct change to main and requires going through a validated pull request. On GitHub, the current form is the ruleset, which replaces the classic rules and can be read without administration rights, which matters to audit tools such as Scorecard.

What a branch ruleset controls

A ruleset is not a single box but a set of locks you enable separately. The table maps each rule to what it prevents. Three deserve your attention first: the mandatory pull request, the approval of the most recent push, which stops anyone adding a commit after the review, and an empty bypass list, without which an administrator overrides every rule.

Rule (GitHub interface)What it prevents
Require a pull request before mergingDirect push to the protected branch
Required approvalsMerging without human validation
Dismiss stale pull request approvals when new commits are pushedAn approval kept after the code changed
Require approval of the most recent reviewable pushA commit added after the review, without fresh approval
Require review from Code OwnersMerging without the designated owners' approval
Require status checks to passMerging with a failing CI
Block force pushesRewriting the branch history
Restrict deletionsDeleting the protected branch

Configuring a ruleset on GitHub

The configuration lives in the repository settings, or in the organisation settings to apply it to several repositories at once. Each step below names the menu to open and the rules to enable.

  1. Create the ruleset

    In the repository, open Settings, then Rules, Rulesets, and choose New ruleset, then New branch ruleset.

  2. Name and target the rule

    • Ruleset name: Protect main
    • Enforcement status: Active
    • Target branches: Add target, then Include default branch

    Add other targets by pattern if needed, such as release/*.

  3. Enable the essential rules

    • Restrict deletions and Block force pushes
    • Require a pull request before merging, with at least 1 approval (2 for a sensitive repository), Dismiss stale pull request approvals, Require review from Code Owners and Require approval of the most recent reviewable push
    • Require status checks to pass, with your CI jobs (tests, lint, build)
  4. Empty the bypass list

    At the top of the page, the Bypass list must stay empty. A role listed there, administrators included, merges without following the rules, which defeats the purpose of the ruleset.

  5. Save

    Click Create. The ruleset applies immediately.

Configuring a protected branch on GitLab

GitLab has no ruleset: protection is set per branch, and approvals are set separately. Approval rules and code owner approval require the Premium or Ultimate tier; on the free tier, only push and merge restrictions are available.

  1. Protect main

    Under Settings, Repository, Protected branches: pick main, set Allowed to merge to Maintainers, Allowed to push and merge to No one, and leave Allowed to force push off.

  2. Require code owner approval

    On the same row, enable Code owner approval, if you have a CODEOWNERS file.

  3. Block risky merges

    Under Settings, Merge requests, tick Pipelines must succeed and All threads must be resolved.

  4. Set the number of approvals

    In the Merge request approvals section, create a rule with at least 1 required approval, and remove the author's right to approve their own merge request.

Checking that the protection works

An untested protection is a hypothesis. The simplest test is to try a direct push to main from an up-to-date clone: the server must refuse it, not accept it and flag it afterwards.

Terminal window
git switch main
echo "# Protection test" >> README.md
git commit -am "test: a direct push must fail"
git push origin main

With a ruleset, GitHub answers with a GH013 error listing the violated rule; classic protection answers with the GH006 error.

remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - Changes must be made through a pull request.

If the push goes through, check the ruleset's target and its bypass list. Then drop the test local commit with git reset --hard origin/main.

Step 2: protect the tags too

Branch protection does not cover tags, and that is exactly the path the tj-actions attack took. A release tag such as v1.4.0 is a name consumers believe to be frozen: if it can be moved, a published version can change content without anyone noticing.

On GitHub, a tag ruleset is created in the same place as branch rulesets, with New tag ruleset. Target every release tag, for example the v* pattern, and enable Restrict updates and Restrict deletions: a published tag can then be neither moved nor deleted, except by a role in the bypass list, which must stay empty.

GitHub also offers immutable releases: once the option is enabled in the repository settings, a published release, its tag and its files can no longer be modified. That setting is what protected version v0.35.0 of trivy-action during the March 2026 compromise, detailed in the Security testing lesson. On GitLab, the equivalent is Settings, Repository, Protected tags, where Allowed to create is set to Maintainers.

Step 3: designate owners with CODEOWNERS

A protection that requires "1 approval" does not say by whom. By default, any collaborator with write access approves, and an attacker who controls two accounts, or has an accomplice, approves their own change. The CODEOWNERS file settles that: it designates the mandatory reviewers of each area of the code, and the forge adds them automatically to every pull request touching it.

How CODEOWNERS works

When a pull request opens, the forge looks at the changed files, finds in CODEOWNERS the last rule matching each one, and adds the owners found as reviewers. Their approval only becomes mandatory if the "Require review from Code Owners" rule is active in branch protection: without it, the file is merely a suggestion.

Creating the CODEOWNERS file

On GitHub, the file goes in .github/CODEOWNERS, at the root or in docs/; on GitLab, at the root, in docs/ or in .gitlab/. The example below starts with a default owner, then protects the areas that give access to secrets, to the image or to the dependencies.

.github/CODEOWNERS
# The LAST matching rule wins:
# general rule first, specific rules after.
# Default owner of everything below
* @acme/maintainers
# CI/CD workflows: they read the repository secrets
.github/workflows/ @acme/security
.github/actions/ @acme/security
.gitlab-ci.yml @acme/security
# Build and deployment: a trapped Dockerfile traps the image
Dockerfile @acme/ops
docker-compose*.yml @acme/ops
helm/ @acme/ops
terraform/ @acme/ops
# Dependencies: the chain's first attack vector
package.json @acme/security
package-lock.json @acme/security
requirements*.txt @acme/security
go.mod @acme/security
go.sum @acme/security
# The CODEOWNERS file itself
.github/CODEOWNERS @acme/security

The last line matters more than it looks: without it, a contributor could remove an area's owners by editing CODEOWNERS in the same pull request as their change, and ask nobody's opinion.

Reading the CODEOWNERS syntax

Patterns follow the .gitignore syntax, with one big difference: order matters, since the last matching rule wins. The table sums up the useful forms and their scope.

PatternMeaningExample
*Every file (default rule)* @acme/maintainers
*.pyEvery file with this extension*.py @acme/python
/src/The src/ folder at the root only/src/ @acme/core
tests/Any tests/ folder anywheretests/ @acme/qa
@aliceA user accountdocs/ @alice
@acme/securityA team in the organisationgo.mod @acme/security

Checking that CODEOWNERS works

GitHub flags syntax errors directly when it displays the CODEOWNERS file, but the only real test is a pull request touching a protected area. The designated reviewers must appear automatically, and merging must stay blocked until they approve.

Terminal window
git switch -c test/codeowners
echo "# test" >> .github/workflows/ci.yml
git commit -am "test: check CODEOWNERS"
git push origin test/codeowners

Open the pull request to main: the @acme/security team must appear among the required reviewers, and the merge button must show "Review required from code owners" as long as the approval is missing.

Step 4: document the rules with CONTRIBUTING.md

CODEOWNERS says who approves, CONTRIBUTING.md explains how to contribute. The document prevents honest mistakes (badly named branch, pull request without a description), spares reviewers repeating the same rules, and serves as audit evidence that the process was written down before the incident. GitHub shows it automatically if it sits at the root, in .github/ or in docs/.

The template below fits on one page, on purpose: an overly long contribution guide goes unread. It names the sensitive areas and their owners, lists what is forbidden, and says where to report a flaw.

CONTRIBUTING.md
# Contributing
Every change goes through a pull request to `main`.
## Process
1. Create a branch from `main`: `feature/…`, `fix/…`, `security/…`.
2. Open a pull request that explains the why, not just the what, and links
the related issue (`Fixes #123`).
3. Wait for a green CI and at least one approval. Sensitive areas need their
owners' approval (CODEOWNERS file).
## Sensitive areas
| Area | Risk | Owners |
|------|------|--------|
| `.github/workflows/` | Access to CI/CD secrets | @acme/security |
| `Dockerfile`, `helm/` | Trapped image or deployment | @acme/ops |
| dependency files | Supply chain attack | @acme/security |
## Forbidden
- Pushing straight to `main` or force pushing to a protected branch.
- Merging without the required owners' approval or with a red CI.
- Disabling a CI check to force a merge through.
## Reporting a vulnerability
Do not open a public issue: use the repository's Security tab
("Report a vulnerability") or write to security@acme.example.
We acknowledge within 48 hours.

Which protection level should you aim for?

Three tiers stand out, from the minimum every repository should apply to the configuration of sensitive repositories. Each one closes one more attack, and each maps to requirements of the SOCLE baseline, quoted in brackets to serve as evidence.

The minimum, for every repository

This first tier closes the widest gap, the direct push to main, and requires a review and a green CI before any merge. It needs no team organisation: a few repository settings are enough, and a personal project can apply it from day one.

  • Mandatory pull request and at least one approval (SOCLE-SRC-GEN-1).
  • Required status checks (SOCLE-SRC-BRA-1).
  • Force push and deletion forbidden on main (SOCLE-SRC-SIG-1).

The standard tier, for a team

This is the tier most teams aim for. It adds the building block that prevents self-approval and locks out bypassing by administrators: at this level, a single compromised account is no longer enough to slip malicious code through.

  • CODEOWNERS active with "Require review from Code Owners" (SOCLE-SRC-BRA-2).
  • Approvals dismissed on every new push (SOCLE-SRC-BRA-3), approval of the most recent push required, empty bypass list.
  • Release tags protected against updates and deletion.

The hardened tier, for a sensitive repository

This last tier targets projects whose compromise would be costly. It requires two approvals from different people to counter collusion, and signed commits to authenticate every author, with Gitsign (in French) for instance.

  • Two-person review (SOCLE-SRC-BRA-4).
  • Signed commits required by branch protection.
  • Audit log of protection changes, reviewed regularly.

These tiers can be measured. The Branch-Protection check of OpenSSF Scorecard scores protection by sequential tiers: a missing approval of the most recent push alone blocks the 6/10 tier, whatever stricter settings are already active.

Which mistakes make the protection useless?

Most of these mistakes share one trait: a protection half enabled, which gives a false sense of security. The most frequent case is a CODEOWNERS file without a rule making it mandatory, so it can be ignored. Read the Consequence column before the fix: it says what an attacker can do as long as the setting is not corrected.

MistakeConsequenceFix
CODEOWNERS without "Require review from Code Owners"Owners are added but not mandatoryEnable the rule in the ruleset
Administrators in the bypass listEvery protection can be bypassedEmpty the bypass list
Unprotected tagsA published version changes contentTag ruleset with Restrict updates and Restrict deletions
* rule placed lastIt overrides every specific ruleDefault rule first
CODEOWNERS with no owner for itselfA contributor removes the owners in their own PRAssign .github/CODEOWNERS to a security team
Named team missing or without write accessLine ignored, review not requiredCheck the team and its rights
Bot token with broad write rightsOne stolen token is enough to change anythingMinimally scoped, expiring, audited tokens

Where does this configuration sit in SLSA Source?

SLSA (in French) has defined, since its version 1.2, a Source track in four levels. The table sums them up with their requirements; the right-hand column says what this lesson's configuration brings to each.

LevelSLSA requirementWhat this lesson brings
L1 Version controlledA version control systemCovered by Git
L2 History and provenanceContinuous, immutable, retained history; provenance attestations issued by the forgeProtected history (force push and deletion forbidden), but no provenance attestation
L3 Continuous technical controlsThe forge enforces the organisation's controls on named referencesBranch and tag rulesets, required checks
L4 Two-party reviewTwo trusted people review every change to protected branchesHardened tier, with two approvals

The nuance matters: the configuration described here is aligned with the controls of levels 3 and 4, but it does not produce the source provenance attestations that level 2 already requires. On its own, it is therefore not enough to claim a SLSA Source level.

Key points

  • Branch protection is the foundation: without it, CODEOWNERS and CONTRIBUTING.md are only suggestions.
  • On GitHub, prefer the ruleset to classic protection, and keep its bypass list empty.
  • Approval of the most recent push stops anyone adding a commit after the review; without it, a review only covers what was actually read.
  • Release tags need their own ruleset: the tj-actions attack rewrote them without touching a single branch.
  • CODEOWNERS designates the mandatory reviewers of sensitive areas, and must protect itself.
  • This configuration aligns with the SLSA Source L3 and L4 controls, without producing the provenance attestations required from L2.
  • Evidence expected by SOCLE-SRC-GEN-1 (in French): the branch protection configuration, mandatory review and direct push forbidden. A screenshot or an export of the ruleset provides it.

Knowledge check

Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

External resources

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn