Part 6: Rulesets That Block Merges, and Five Demo PRs That Must Fail

· 20 min read

Indonesia
//TUTORIAL · 6 OF 10 — A Secure CI/CD Pipeline with Go, GitHub Actions, Vault, and Dokploy

In Part 5, pr-checks.yml and source-branch.yml started running on every PR. The problem is that a red check doesn't force anything yet. The Merge button is still there, and you can still click it. So the gate is only half built.

In this part I close that gap with GitHub rulesets on development and main. Once they're active, a PR with a red check can't be merged, a direct push is rejected, and I can't skip any of it either, even as the repo admin.

Then comes the part I enjoy most: proving it. I open five deliberately bad PRs (a leaked secret, a command injection, a vulnerable dependency, a root container, and a PR that skips development). Each one has to be stopped by a different gate.

Note

The way I think about it: the workflows from Part 5 are the metal detector. The ruleset is the guard who actually stops people when the detector beeps. A detector without a guard just beeps while people walk through.

What a ruleset is, and why not branch protection

A ruleset is a set of rules GitHub enforces on its servers whenever someone pushes or merges into specific branches. Because the check happens server side, nothing you do on your laptop gets around it.

GitHub has two similar features. I went with rulesets for these reasons:

Branch protection (old)

Ruleset (new)

Rules per branch

One rule per branch pattern

Can be layered

Who can see them

Admins

Anyone with read access (transparent)

Change history

None

Yes, plus Rule Insights (a log of rejected/allowed pushes)

JSON export/import

No

Yes, so they can live in git

Admin bypass

One checkbox

Explicit, through bypass_actors

I only use five kinds of rule:

Rule (type)

Meaning

Why

deletion

The branch can't be deleted

development and main can't disappear, on purpose or by accident

non_fast_forward

No force push

A force push can rewrite history and drop commits that were already reviewed

pull_request

Every change must go through a PR

There's no way in except through the checks

required_status_checks

Specific checks must be green

This is what ties the ruleset to the Part 5 workflows

bypass_actors: []

Nobody may skip the rules, admins included

The anti-goal from Part 1: "an admin can bypass the pipeline"

That last row matters most. If an admin can bypass the gate, the gate means nothing. One "it's an emergency, just merge it" and every guarantee is gone.

Step 0: the workflow has to run once first

Rulesets require checks by name. If the name in the ruleset doesn't match the name the workflow reports, the PR waits forever with "Expected — Waiting for status to be reported".

So order matters. The Part 5 workflows must have run at least once on a PR. After that, the check names exist on GitHub, they show up in the ruleset UI's picker, and I can read them from the API.

I pulled the list from the workflow PR in Part 5:

bash
R=dimss113/secure-cicd-goSHA=$(gh api repos/$R/pulls/2 --jq .head.sha)gh api repos/$R/commits/$SHA/check-runs \  --jq '.check_runs[] | .name + "  " + .conclusion + "  app=" + (.app.id|tostring) + " " + .app.slug'

SHA is the PR's latest commit. The check-runs endpoint returns every check reported for that commit, including the app that sent it.

Check:

text
Semgrep OSS  success  app=57789 github-advanced-securityTrivy  success  app=57789 github-advanced-securitygitleaks  success  app=57789 github-advanced-securitydependency-review  success  app=15368 github-actionstrivy-image  success  app=15368 github-actionsgovulncheck  success  app=15368 github-actionsgitleaks  success  app=15368 github-actionssemgrep  success  app=15368 github-actionstest  success  app=15368 github-actionstrivy-config  success  app=15368 github-actions

The seven checks with app=15368 are my jobs in pr-checks.yml (the check name is the job ID). The other three come from Code scanning, created automatically because the workflow uploads SARIF.

Something that tripped me up: gitleaks shows up twice, from two different apps.

Danger

Pin each check to the GitHub Actions app

If the ruleset only says "a check named gitleaks must be green", a check called gitleaks from anywhere counts. That opens two problems. First, name collisions: the Code scanning check can be counted instead of my job. Second, spoofing: anyone with a token that can write commit statuses can post test = success through the API without any test running. With "integration_id": 15368, GitHub only accepts checks sent by GitHub Actions. 15368 is the GitHub Actions app ID on github.com, the same for every repo.

Step 1: test the gates first, and find two holes

Before adding the rulesets, I ran all five demos against a copy of the repo. A gate that has never seen a bad case isn't proven to work. And sure enough, two problems showed up.

Hole 1: a typo in trivy-config. I had written limit-se-for-sarif instead of limit-severities-for-sarif. GitHub had already flagged it as a yellow annotation in the run summary, but the job stayed green, so it was easy to miss. The effect was that the SARIF report included every severity (LOW and MEDIUM too), which made the Security tab noisy. The gate itself was fine. The lesson: read the yellow annotations in the run summary.

Hole 2: Semgrep didn't catch command injection. This one was more serious. I scanned the demo code, exec.Command("sh", "-c", <input from the URL>), with a few rulesets:

Semgrep ruleset

Result

p/golang (what I was using)

No findings

p/gosec

No findings

p/default

dangerous-exec-command (ERROR)

p/golang is mostly best-practice rules. The dangerous-exec-command security rule lives in p/default. I also ran p/default on the current code: zero findings, so it doesn't turn other PRs red for no reason.

The fix is two lines in pr-checks.yml. This is how they look in the final repo:

.github/workflows/pr-checks.yml yaml
  semgrep:    # ...          semgrep scan --config p/golang --config p/default --config p/secrets \            --severity ERROR --error --metrics=off \  # ...  trivy-config:    # ...          severity: HIGH,CRITICAL          limit-severities-for-sarif: true

(Trimmed to the changed lines.) You can pass --config more than once, and Semgrep merges all the rules.

Check:

bash
grep -n "limit-severities-for-sarif" .github/workflows/pr-checks.yml   # 2 lines: trivy-config and trivy-imagesemgrep scan --config p/golang --config p/default --config p/secrets \  --severity ERROR --error --metrics=off --quiet .echo "exit=$?"                                                         # exit=0

I don't commit this yet. It goes into the first PR that runs under the rulesets.

Step 2: rulesets as code

You can build a ruleset by clicking through Settings → Rules. I went with a JSON file plus the API, for the same reason as vault/policies/ in Part 2: it gets reviewed through PRs, it can be reapplied exactly, and no checkbox gets missed.

This is protect-development:

docs/rulesets/protect-development.json json
{  "name": "protect-development",  "target": "branch",  "enforcement": "active",  "bypass_actors": [],  "conditions": {    "ref_name": {      "include": ["refs/heads/development"],      "exclude": []    }  },  "rules": [    { "type": "deletion" },    { "type": "non_fast_forward" },    {      "type": "pull_request",      "parameters": {        "required_approving_review_count": 0,        "dismiss_stale_reviews_on_push": false,        "require_code_owner_review": false,        "require_last_push_approval": false,        "required_review_thread_resolution": false,        "allowed_merge_methods": ["merge"]      }    },    {      "type": "required_status_checks",      "parameters": {        "strict_required_status_checks_policy": true,        "do_not_enforce_on_create": false,        "required_status_checks": [          { "context": "test", "integration_id": 15368 },          { "context": "gitleaks", "integration_id": 15368 },          { "context": "semgrep", "integration_id": 15368 },          { "context": "govulncheck", "integration_id": 15368 },          { "context": "dependency-review", "integration_id": 15368 },          { "context": "trivy-config", "integration_id": 15368 },          { "context": "trivy-image", "integration_id": 15368 }        ]      }    }  ]}

protect-main is the same, with three differences:

docs/rulesets/protect-main.json json
  "conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } },  ...        "strict_required_status_checks_policy": false,        ...          { "context": "trivy-image", "integration_id": 15368 },          { "context": "source-branch", "integration_id": 15368 }

(Trimmed. Everything else is identical to protect-development.)

Note

The files in the repo have one more check

If you open docs/rulesets/ in the repo, both files also require an exceptions check. That job only arrives in Part 9 (the exception process). At this point it doesn't exist, so don't require it yet, or every PR will wait forever.

The keys worth explaining:

Key

Meaning

enforcement: "active"

Enforced right away. disabled turns it off

bypass_actors: []

Empty: nobody may skip it, admins included

ref_name.include

The branches it applies to, as full refs

required_approving_review_count: 0

I work alone, and GitHub doesn't let you approve your own PR. A real team wants at least 1

allowed_merge_methods: ["merge"]

Merge commits only. Squash and rebase are off

strict_required_status_checks_policy

"Require branches to be up to date". true on development, false on main

do_not_enforce_on_create: false

The rules still apply when a branch is first created

context

The check name, which is the job ID

integration_id: 15368

The check must come from GitHub Actions

Why merge commits only

This isn't a style preference. In Part 8, the production deploy doesn't rebuild. It finds the head commit of the development → main PR through the merge commit on main, then looks up the sha-<commit> image that already ran in dev. Squash and rebase create new commits with different SHAs, and that link breaks. So I lock the merge method now, on both branches to keep things consistent.

Why strict differs between the branches

Up to date means the PR branch must already contain the latest commits from the target branch. Say PR A and PR B both start from the same development. A merges first. B is now behind, and its checks ran without A's changes. Each can be green on its own while the combination is broken. With strict: true, B has to be updated and its checks rerun. That's what I want on development.

main is different. Every development → main merge creates a merge commit on main that development doesn't have. With strict on, the next release PR is always behind, and you'd have to merge main back into development every time. Annoying, and it clutters history. Since PRs into main always come from a development that already passed every check, I turned strict off on main.

Why source-branch is only on main

source-branch.yml only runs for PRs into main. If I required it on development too, the check would never appear on PRs into development, and they'd wait forever.

Check:

bash
jq -e . docs/rulesets/protect-development.json > /dev/null && echo OK-devjq -e . docs/rulesets/protect-main.json > /dev/null && echo OK-main

jq -e . exits non-zero on a stray comma or bracket. You should see OK-dev and OK-main.

Step 3: apply them through the API

bash
gh api -X POST repos/$R/rulesets --input docs/rulesets/protect-development.json --jq '.id, .name, .enforcement'gh api -X POST repos/$R/rulesets --input docs/rulesets/protect-main.json --jq '.id, .name, .enforcement'

POST repos/$R/rulesets creates a new ruleset from the JSON file. The output is the ID (a number), the name, and active. Write both IDs down.

Warning

POST only once

Running POST again creates a second ruleset with the same name. To change one, edit the JSON and run gh api -X PUT repos/$R/rulesets/<ID> --input <file>. If you already have a duplicate, delete one with gh api -X DELETE repos/$R/rulesets/<ID>.

Check:

bash
gh api repos/$R/rulesets --jq '.[] | "\(.id)  \(.name)  \(.enforcement)"'gh api repos/$R/rules/branches/development --jq '.[].type'gh api repos/$R/rules/branches/main --jq '.[].type'

rules/branches/<branch> shows the rules actually in effect on that branch, combined across every ruleset. Both branches should list deletion, non_fast_forward, pull_request, required_status_checks.

In the UI, open Settings → Rules → Rulesets → protect-development. You should see Enforcement status: Active, an empty Bypass list, and seven checks under Require status checks to pass, each labeled GitHub Actions.

Step 4: a direct push must be rejected

Make sure both rulesets are active first. If they aren't, this test push succeeds and the empty commit lands on development.

bash
git stashgit switch developmentgit commit --allow-empty -m "test: direct push must be rejected"git push origin development

git stash parks the Step 1 and 2 changes. --allow-empty makes a commit with no file changes, which is enough to test a push.

Check (roughly):

text
remote: error: GH013: Repository rule violations found for refs/heads/development.remote: - Changes must be made through a pull request.remote: - Required status checks "test", "gitleaks", ... are expected. ! [remote rejected] development -> development (push declined due to repository rule violations)

Rejected, even though I'm the repo admin. Clean up the test commit and go back to the working branch:

bash
git reset --hard origin/developmentgit switch ci/harden-gatesgit stash pop

reset --hard is safe here because the only thing it throws away is that empty commit.

Step 5: the first PR under the rulesets, and trying --admin

bash
git add .github/workflows/pr-checks.yml docs/rulesets/git commit -m "ci: harden gates and record branch rulesets"git push -u origin ci/harden-gatesgh pr create --base development \  --title "ci: harden gates and record branch rulesets" \  --body "Fixes limit-severities-for-sarif typo, adds Semgrep p/default, records the rulesets applied via the API."

While the checks were still running, I tried to force it through with admin rights:

bash
gh pr merge --merge --admin

--admin means "merge with admin rights and ignore the rules". Because bypass_actors is empty, this is rejected with Repository rule violations found ... required status checks ... are expected.

Once the checks are green, merge normally:

bash
gh pr checks --watchgh pr merge --merge --delete-branchgit switch development && git pull

Tip

"This branch is out-of-date with the base branch"

That's the strict rule doing its job. Run gh pr update-branch, let the checks rerun, then merge.

Every demo below must start after this PR is merged. The command injection demo depends on p/default.

Five demo PRs that must fail

Every demo follows the same pattern:

text
latest development → demo/... branch → change one "bad" thing → commit → push → PR→ red check → "Merging is blocked" → CLOSE the PR (don't merge) → delete the branch

The commands I reuse each time:

Command

What it does

git switch development && git pull

Start from the latest development so demos don't bleed into each other

gh pr checks --watch

Wait for every check. It exits non-zero if one fails, which is what we want

gh pr view --json mergeStateStatus --jq .mergeStateStatus

Should print BLOCKED

gh pr close --delete-branch

Close without merging and delete the branch

On each demo PR, here's what you should see at the bottom of the PR page: the list of checks with one (or two) red crosses and green ticks for the rest, then a grey box saying "Merging is blocked" because a required status check failed. The merge button is disabled. Click Details on the red check to read the log that explains why.

Demo 1: a leaked secret, caught by gitleaks

bash
git switch development && git pullgit switch -c demo/leak-secretmkdir -p internal/demoKEY=$(openssl rand -hex 20)printf 'package demo\n\n// Demo only: fake credential that the secret scanner must catch.\nconst apiKey = "%s"\n\nfunc Key() string { return apiKey }\n' "$KEY" > internal/demo/leak.go

openssl rand -hex 20 produces 40 random hex characters. It looks like an API key but works nowhere. A variable named apiKey holding a long random string is exactly the pattern Gitleaks' generic-api-key rule looks for.

Two things I learned while picking a fake secret. The AWS example key ending in EXAMPLE is ignored by Gitleaks. And if you use a real GitHub token pattern, GitHub's push protection rejects the git push itself, so CI never runs (that's evidence too, the first layer, but not what I wanted to show here).

Try it locally first:

bash
git add internal/demo/leak.gogit commit -m "demo: hardcode an api key"gitleaks git . --log-opts="HEAD~1..HEAD" --redact --no-bannerecho "exit=$?"
text
Finding:     const apiKey = "REDACTED"RuleID:      generic-api-keyFile:        internal/demo/leak.goWRN leaks found: 1exit=1

Push, open a PR into development, and wait for the checks.

Check: gitleaks is red, the other six are green, and the status is BLOCKED. Security → Code scanning shows a generic-api-key alert for this PR.

Warning

The commit stays on GitHub

After the PR is closed, its commit is still reachable through refs/pull/<n>/head. That's why you only use random values here. If a real secret leaks, closing the PR isn't enough: rotate it right away.

Demo 2: command injection, caught by semgrep

internal/httpapi/debug.go go
package httpapi import (	"net/http"	"os/exec") // Demo only: runs a shell command taken straight from the query string.func debugExec(w http.ResponseWriter, r *http.Request) {	out, _ := exec.Command("sh", "-c", r.URL.Query().Get("cmd")).CombinedOutput()	_, _ = w.Write(out)}

r.URL.Query().Get("cmd") is fully controlled by whoever sends the request, and exec.Command("sh", "-c", ...) runs it as a shell command. GET /debug?cmd=cat /run/config/config.json would be enough to leak the JWT and AES secrets from Part 4. That's Remote Code Execution. The function isn't even wired to the router, but Semgrep flags it anyway, because it reads code instead of running the app.

Check locally:

bash
semgrep scan --config p/golang --config p/default --config p/secrets --severity ERROR --error --metrics=off .echo "exit=$?"

You should get a go.lang.security.audit.dangerous-exec-command.dangerous-exec-command finding in internal/httpapi/debug.go, then exit=1.

Check: semgrep is red and the status is BLOCKED. In the Files changed tab, the exec.Command line carries a Code scanning annotation.

If this demo comes back green, the branch was almost certainly created before the Step 5 PR merged. Before that fix, this demo passed. That's the point: I tested my own gate instead of just installing a tool.

Demo 3: a vulnerable dependency, caught by govulncheck and dependency-review

bash
git switch development && git pullgit switch -c demo/vuln-depgo get github.com/golang-jwt/jwt/v5@v5.2.1grep jwt go.mod   # require github.com/golang-jwt/jwt/v5 v5.2.1

The repo uses v5.3.1. I downgrade to v5.2.1, which has CVE-2025-30204 (GO-2025-3553, GHSA-mh63-6h87-95cp, High severity): a crafted token header makes the parser allocate too much memory, so the server can run out of it. Fixed in v5.2.2.

Check locally:

bash
go build ./... && go test ./...go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...

Build and tests still pass. The code works fine, and that's exactly what makes it dangerous. Then govulncheck:

text
Vulnerability #1: GO-2025-3553    Found in: github.com/golang-jwt/jwt/v5@v5.2.1    Fixed in: github.com/golang-jwt/jwt/v5@v5.2.2      #1: internal/token/token.go:52:31: token.Issuer.Verify calls jwt.ParseWithClaims, which eventually calls jwt.Parser.ParseUnverified

Trace #1 proves our code really reaches the vulnerable function: Verify backs the GET /v1/me endpoint.

Check:

  • govulncheck is red (a reachable vuln).

  • dependency-review is red, because the PR introduces a version with a High advisory (fail-on-severity: high). The job summary shows GHSA-mh63-6h87-95cp.

  • trivy-image is green, but its report lists CVE-2025-30204. It's HIGH, not CRITICAL, so it doesn't block.

  • The status is BLOCKED.

Two different tools catch the same problem from different angles. That's what I mean by defense in depth. If dependency-review comes back green, check that Dependency graph is enabled under Settings → Advanced Security.

Demo 4: a container running as root, caught by trivy-config

In the Dockerfile, I swap the user line:

Dockerfile dockerfile
# beforeUSER 65532:65532# after (demo)USER root

The process in the container now runs as UID 0. If the app is compromised, say through a command injection like demo 2, the attacker has root in the container straight away, and the path to the host through a container escape bug gets shorter.

Check locally:

bash
trivy config --severity HIGH,CRITICAL --exit-code 1 .echo "exit=$?"
text
Dockerfile (dockerfile)Failures: 1 (HIGH: 1, CRITICAL: 0)DS-0002 (HIGH): Last USER command in Dockerfile should not be 'root'exit=1

Check: trivy-config is red with DS-0002, and the status is BLOCKED.

Demo 5: a feature branch straight into main, caught by source-branch

This last one is different. The code is clean. What's wrong is the path.

bash
git switch development && git pullgit switch -c demo/main-from-featureecho "Demo: this branch tries to skip development." > docs/demo-skip-development.mdgit add docs/demo-skip-development.mdgit commit -m "demo: try to reach main without development"git push -u origin demo/main-from-featuregh pr create --base main --title "demo: feature straight to main" --body "Must be blocked by source-branch. Do not merge."

The thing under test is --base main. This is the job that catches it, from Part 5:

.github/workflows/source-branch.yml yaml
      - name: Only development may open a PR into main        env:          HEAD_REF: ${{ github.head_ref }}        run: |          if [ "$HEAD_REF" != "development" ]; then            echo "::error::PR into main must come from development, not '$HEAD_REF'"            exit 1          fi

Check: source-branch is red with PR into main must come from development, not 'demo/main-from-feature'. The seven pr-checks jobs are green. The status is BLOCKED.

So even clean code can't reach production without going through development, where it gets deployed and tested first.

Demo summary

Demo PR

What changed

Red check

Why

demo/leak-secret

const apiKey = "<40 random hex>"

gitleaks

generic-api-key pattern

demo/cmd-injection

exec.Command("sh", "-c", query)

semgrep

dangerous-exec-command from p/default

demo/vuln-dep

golang-jwt/jwt/v5 down to v5.2.1

govulncheck, dependency-review

GO-2025-3553, reachable through Verify, High severity

demo/root-container

USER root in the Dockerfile

trivy-config

DS-0002 (HIGH)

demo/main-from-feature

Feature PR → main

source-branch

Head branch isn't development

For evidence, these two commands help:

bash
gh pr list --state closed --search "demo in:title" --json number,title,state \  --jq '.[] | "#\(.number) \(.title) \(.state)"'gh api repos/$R/rulesets/rule-suites --jq '.[] | "\(.pushed_at)  \(.ref)  \(.result)  \(.actor_name)"'

The first lists every closed demo PR. The second reads Rule Insights: every push the rulesets evaluated, with the result. The direct push from Step 4 should be recorded as fail. In the UI it's under Settings → Rules → Insights.

Limits I should be honest about

Rulesets stop mistakes and shortcuts. They don't stop everything.

Limit

Explanation and mitigation

An admin can edit or delete a ruleset

I'm the only admin, so technically I could disable a ruleset and push. That change lands in the ruleset history and in the committed JSON. In a company, rulesets are set at the organization level by the security team, and repo admins can't change them

0 approvals

I work alone. In a team: at least 1 approval plus require_last_push_approval

A PR can change its own gate workflow

For pull_request events, GitHub runs the pr-checks.yml from the PR branch. A developer could replace the semgrep job body with run: echo ok, keep the name semgrep, get a green status, and the ruleset is happy because it only checks the name. In a team: CODEOWNERS for /.github/ plus required code owner review, or required workflows from a central repo owned by the security team

The gate is only as good as the scanner rules

Proven in Step 1: p/golang alone wasn't enough. Retest the gates with bad cases every time their config changes

The third one is, to me, the most important lesson in this part: a gate should be owned by someone other than the people it guards.

Recap

  • Two active rulesets, protect-development and protect-main, stored as JSON in docs/rulesets/ and applied through the API.

  • An empty bypass list. A direct push is rejected with GH013, and gh pr merge --admin is rejected too.

  • Seven pr-checks jobs must be green, pinned to GitHub Actions with integration_id: 15368. main also requires source-branch.

  • Merge commits only, so Part 8 can promote an image without rebuilding it.

  • Two gate holes found and fixed before the rulesets went on: the limit-severities-for-sarif typo, and Semgrep needing p/default.

  • Five demo PRs, five different gates, all BLOCKED.

Code can no longer reach development or main without passing the gates. But code that passes still doesn't run anywhere. In Part 7, I set up Dokploy with two environments, dev and production, with the Vault Agent sidecar and auto-deploy turned off, so that later only CI can trigger a deploy.

Related articles