//PART 6 OF 10
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
- 01Part 1: Why I Built a Secure CI/CD Pipeline, and Getting the Tools Ready
- 02Part 2: Vault Secrets, Policies, and an AppRole per Environment
- 03Part 3: A Small Go App That Reads Its Secrets from a File and Refuses to Start on Bad Config
- 04Part 4: A Vault Agent Sidecar and Docker Compose, Secrets Without the App Knowing Vault Exists
- 05Part 5: The GitHub Repo and the PR Workflow as a Security Gate
- 06Part 6: Rulesets That Block Merges, and Five Demo PRs That Must Fail
- 07Part 7: Setting Up the Dev and Production Environments in Dokploy
- 08Part 8: The Deploy Workflow, Build Once, Scan, SBOM, GHCR, Then Promote to Production
- 09Part 9: An Exception Process for Security Findings You Can't Fix Yet
- 10Part 10: CI Gets Its Dokploy Token from Vault through GitHub OIDC, and Wrapping Up the Series
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 |
I only use five kinds of rule:
Rule ( | Meaning | Why |
|---|---|---|
| The branch can't be deleted |
|
| No force push | A force push can rewrite history and drop commits that were already reviewed |
| Every change must go through a PR | There's no way in except through the checks |
| Specific checks must be green | This is what ties the ruleset to the Part 5 workflows |
| 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:
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:
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-actionsThe 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 |
|---|---|
| No findings |
| No findings |
|
|
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:
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:
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=0I 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:
{ "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:
"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 |
|---|---|
| Enforced right away. |
| Empty: nobody may skip it, admins included |
| The branches it applies to, as full refs |
| I work alone, and GitHub doesn't let you approve your own PR. A real team wants at least 1 |
| Merge commits only. Squash and rebase are off |
| "Require branches to be up to date". |
| The rules still apply when a branch is first created |
| The check name, which is the job ID |
| 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:
jq -e . docs/rulesets/protect-development.json > /dev/null && echo OK-devjq -e . docs/rulesets/protect-main.json > /dev/null && echo OK-mainjq -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
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:
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.
git stashgit switch developmentgit commit --allow-empty -m "test: direct push must be rejected"git push origin developmentgit 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):
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:
git reset --hard origin/developmentgit switch ci/harden-gatesgit stash popreset --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
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:
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:
gh pr checks --watchgh pr merge --merge --delete-branchgit switch development && git pullTip
"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:
latest development → demo/... branch → change one "bad" thing → commit → push → PR→ red check → "Merging is blocked" → CLOSE the PR (don't merge) → delete the branchThe commands I reuse each time:
Command | What it does |
|---|---|
| Start from the latest |
| Wait for every check. It exits non-zero if one fails, which is what we want |
| Should print |
| 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
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.goopenssl 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:
git add internal/demo/leak.gogit commit -m "demo: hardcode an api key"gitleaks git . --log-opts="HEAD~1..HEAD" --redact --no-bannerecho "exit=$?"Finding: const apiKey = "REDACTED"RuleID: generic-api-keyFile: internal/demo/leak.goWRN leaks found: 1exit=1Push, 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
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:
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
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.1The 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:
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:
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.ParseUnverifiedTrace #1 proves our code really reaches the vulnerable function: Verify backs the GET /v1/me endpoint.
Check:
govulncheckis red (a reachable vuln).dependency-reviewis red, because the PR introduces a version with a High advisory (fail-on-severity: high). The job summary showsGHSA-mh63-6h87-95cp.trivy-imageis 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:
# beforeUSER 65532:65532# after (demo)USER rootThe 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:
trivy config --severity HIGH,CRITICAL --exit-code 1 .echo "exit=$?"Dockerfile (dockerfile)Failures: 1 (HIGH: 1, CRITICAL: 0)DS-0002 (HIGH): Last USER command in Dockerfile should not be 'root'exit=1Check: 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.
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:
- 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 fiCheck: 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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
| GO-2025-3553, reachable through |
|
|
| DS-0002 (HIGH) |
| Feature PR → |
| Head branch isn't |
For evidence, these two commands help:
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 |
A PR can change its own gate workflow | For |
The gate is only as good as the scanner rules | Proven in Step 1: |
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-developmentandprotect-main, stored as JSON indocs/rulesets/and applied through the API.An empty bypass list. A direct push is rejected with
GH013, andgh pr merge --adminis rejected too.Seven
pr-checksjobs must be green, pinned to GitHub Actions withintegration_id: 15368.mainalso requiressource-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-sariftypo, and Semgrep needingp/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.