Skip to main content

CI Gates and PR Status Checks

A green master branch is only valuable if you trust what's on it, and a tag-driven prod deploy is only safe if the tag points at code that was actually reviewed. This chapter adds the GitHub Actions jobs that run on every pull request, the branch-protection rules that make those jobs required before merge, and the Repository Ruleset that prevents v* tags from being pushed against arbitrary commits.

The actual deploy workflows (deploy-staging.yml, deploy-prod.yml) are written in Part 7 once the Kustomize overlays and the namespace-scoped CI service accounts exist. Part 6 stops at "PRs are gated; tags are protected".

Prerequisites

  • The image pipeline from Part 5 produces tagged images on push and the pull-smoke Job succeeded on both clusters.
  • You're a repository admin (needed for branch protection and rulesets).

Step 1 — The PR workflow file

Put all five checks in one workflow so they appear as one row in the PR check list. Create .github/workflows/pr.yml:

name: pr

on:
pull_request:
branches: [master]

permissions:
contents: read
pull-requests: write

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm', cache-dependency-path: app/package-lock.json }
- run: npm ci
working-directory: app
- run: npm run lint
working-directory: app
- run: npm run format
working-directory: app

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm', cache-dependency-path: app/package-lock.json }
- run: npm ci
working-directory: app
- run: npm test -- --coverage
working-directory: app
- uses: actions/upload-artifact@v4
with:
name: coverage
path: app/coverage

# `kubeconform` validates rendered manifests against the Kubernetes JSON
# schemas (built-in kinds + CRDs from the Datree catalogue), so the PR
# catches typos and bad fields before they ever reach a cluster.
manifests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubeconform
run: |
curl -sL https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz \
| tar -xz -C /usr/local/bin kubeconform
- name: Install kustomize
run: |
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/
- name: Render and validate every overlay
run: |
set -euo pipefail
[ -d k8s/overlays ] || { echo "no k8s/overlays yet — nothing to validate"; exit 0; }
for overlay in k8s/overlays/*/; do
echo "=== $overlay ==="
kustomize build "$overlay" \
| kubeconform -strict -summary -ignore-missing-schemas \
-schema-location default \
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
done

trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build local image for scanning
run: docker build -t expressapp:pr -f app/Dockerfile app
- name: Trivy scan (HIGH, CRITICAL — fixable only)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: expressapp:pr
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
format: 'table'

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- name: Detect docs changes
id: changed
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q '^docs/'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Install
if: steps.changed.outputs.run == 'true'
run: npm ci
- name: Build (onBrokenLinks: throw)
if: steps.changed.outputs.run == 'true'
run: npm run build

Notes:

  • The manifests job skips silently before Part 7 creates k8s/overlays/. From Part 7 onward it validates every overlay.
  • The manifests job uses the Datree CRD catalogue so ServiceMonitor, SealedSecret, and other CRDs added in later chapters still validate.
  • The trivy job builds the image locally rather than pulling from DOCR. That keeps the gate working even before DOCR credentials are wired into PR contexts (and PRs from forks should never get registry credentials).
  • ignore-unfixed: true means the gate fires only on CVEs that have a fix. A noisy CVE in glibc with no upstream patch shouldn't block merges.

Commit and open a PR to confirm the five checks (lint, test, manifests, trivy, docs) appear:

git checkout -b chore/pr-gates
git add .github/workflows/pr.yml
git commit -m "ci: PR gates (lint, test, kubeconform, trivy, docusaurus)"
git push -u origin chore/pr-gates
gh pr create --fill
gh pr checks --watch

Step 2 — Branch protection on master

Configure protection with gh api. Replace <OWNER>/<REPO> with the app repo.

gh api -X PUT "repos/<OWNER>/<REPO>/branches/master/protection" \
-H "Accept: application/vnd.github+json" \
-F required_status_checks.strict=true \
-F 'required_status_checks.contexts[]=lint' \
-F 'required_status_checks.contexts[]=test' \
-F 'required_status_checks.contexts[]=manifests' \
-F 'required_status_checks.contexts[]=trivy' \
-F 'required_status_checks.contexts[]=docs' \
-F enforce_admins=true \
-F required_pull_request_reviews.required_approving_review_count=1 \
-F required_pull_request_reviews.dismiss_stale_reviews=true \
-F required_linear_history=true \
-F allow_force_pushes=false \
-F allow_deletions=false \
-F restrictions=

Verify in the GitHub UI under Settings → Branches → Branch protection rules. The five checks must all show Required.

Step 3 — A Repository Ruleset for v* tags

GitHub's legacy tag-protection endpoint is closing down; the replacement is Repository Rulesets. Rulesets cover branches and tags with the same model and let you bypass them per actor.

Create a ruleset that restricts who can push or move tags matching v*, and that requires the tagged commit to have history reachable from master (the second condition is "Restrict updates"; see notes below).

Save as cluster-bootstrap/github/ruleset-v-tags.json:

{
"name": "Protect v* release tags",
"target": "tag",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/tags/v*"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "creation"
}
],
"bypass_actors": [
{
"actor_id": 0,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}

Apply with the Rulesets API:

gh api -X POST "repos/<OWNER>/<REPO>/rulesets" \
-H "Accept: application/vnd.github+json" \
--input cluster-bootstrap/github/ruleset-v-tags.json

bypass_actors lists the role/team/app IDs allowed to bypass the ruleset (the entry above is a placeholder; replace actor_id: 0 with the real maintainer-role ID for your repo. gh api repos/<OWNER>/<REPO>/roles lists them).

The ruleset alone can't enforce "this tag points at a commit reachable from master". For that you need the runtime check in Step 4. Together they're the belt-and-suspenders pattern the spec calls for: the ruleset stops non-maintainers from ever pushing a v* tag, and the provenance check stops a maintainer pushing one against an arbitrary commit by mistake.

Step 4 — The tag-on-master provenance check, defined here

Part 7 will glue this check onto the prod deploy workflow. The job itself is defined in this chapter so the policy lives next to the other CI gates.

Save the snippet at .github/workflows/_provenance.yml-style fragment for reference (it isn't a standalone workflow; Part 7 inlines it as the first job of deploy-prod.yml):

# Inline this job at the top of any tag-triggered workflow that needs to
# refuse tags that do not point at protected `master`.
provenance-check:
runs-on: ubuntu-latest
outputs:
tag-sha: ${{ steps.resolve.outputs.tag-sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- id: resolve
name: Resolve tag → SHA
run: |
TAG="${GITHUB_REF##refs/tags/}"
TAG_SHA="$(git rev-list -n 1 "$TAG")"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "tag-sha=$TAG_SHA" >> "$GITHUB_OUTPUT"
- name: Tag must be reachable from origin/master
env:
TAG_SHA: ${{ steps.resolve.outputs.tag-sha }}
run: |
git fetch origin master:refs/remotes/origin/master
if ! git merge-base --is-ancestor "$TAG_SHA" origin/master; then
echo "::error::Tag commit $TAG_SHA is not an ancestor of origin/master."
echo "Refusing to deploy unreviewed code."
exit 1
fi
echo "OK: $TAG_SHA is on protected master."

Key properties:

  • It runs before any cluster credentials are loaded. The dependent deploy job declares needs: provenance-check and only then mounts the kubeconfig.
  • It uses git merge-base --is-ancestor so a force-pushed tag pointing at an old commit that was on master still passes. That's the correct semantics for "this code was reviewed".

Step 5 — Manual verification of the gates

Bad path 1: push a non-master commit and tag it (after Part 7's deploy workflow exists).

git checkout -b bad-tag-test
echo "// drift" >> app/server.js
git commit -am "drift"
git push -u origin bad-tag-test
git tag v0.0.99
git push origin v0.0.99

Expected: the provenance-check job in deploy-prod.yml fails with Tag commit ... is not an ancestor of origin/master. The deploy job is never reached. The Repository Ruleset may also reject the tag push outright depending on actor; that's the first line of defense.

Clean up:

git push origin --delete v0.0.99
git push origin --delete bad-tag-test
git tag -d v0.0.99

Bad path 2: open a PR with a known-vulnerable dependency. The trivy check fails and the merge button is greyed out.

Good path: merge a PR, watch staging deploy (Part 7), then tag a release on master:

git switch master && git pull
git tag v0.1.0
git push origin v0.1.0
gh run watch
# provenance-check: pass
# deploy: paused waiting for prod environment approval (Part 7)

Step 6 — Required reviewers on the prod environment

In Settings → Environments → prod, configure required reviewers. This is the gate that pauses the deploy job after provenance-check passes. The deploy job declares environment: prod to opt into it (you'll wire it up in Part 7).

Completion signal

  • A pull request can't be merged unless lint, test, manifests, trivy, and (when docs/ changes) docs all pass. The merge button is greyed out otherwise.
  • gh api repos/<OWNER>/<REPO>/branches/master/protection shows the five required checks plus linear history.
  • gh api repos/<OWNER>/<REPO>/rulesets lists "Protect v* release tags" with enforcement: active.
  • The provenance-check snippet is checked in and ready for Part 7 to wire into the prod deploy workflow.

You're ready for 07. Manifests with Kustomize.