Kubernetes Manifest Validation in CI: Catching Security Issues Before Deployment
Problem
Kubernetes admission controllers — Kyverno, OPA Gatekeeper, the built-in PodSecurity admission plugin — are the last line of defence before a workload runs in your cluster. They are not the right place to first discover a security problem. When a deployment fails admission at the cluster gate, a developer has already merged a PR, triggered a deploy job, waited for image builds, and then received a cryptic rejection with no inline code context and no link to the policy they violated.
The gap between YAML authoring and runtime admission is where security debt accumulates:
- Developers write manifests with no feedback.
runAsNonRoot: false, missingsecurityContext,privileged: true— these issues are invisible until the manifest hits admission control or, worse, until a workload runs unchallenged in a cluster with no admission controller. - Admission controllers only fire at deploy time. A failed admission check surfaces minutes to hours after the code was written, in a context (a deploy pipeline stage) that is far from the developer’s editor and PR.
- Helm charts mask raw manifests. Chart authors render templates once to verify they work, not to verify they comply with security policy. Rendered output can differ substantially from the chart’s template source, and only the rendered output matters to the cluster.
- Schema drift causes silent failures. Manifests using deprecated API versions (
extensions/v1beta1 Ingress, removed in 1.22) apply successfully in a lenient cluster but fail validation entirely in the target version. Catching this in CI prevents broken deployments. - No organisational policy enforcement in development. Teams have opinionated rules — required labels, banned image registries, mandatory resource limits — that are expressed in admission policies but never visible to developers until a deploy bounces.
Target systems: Kubernetes 1.26+; GitHub Actions; Helm 3.x; kubesec 2.x; Trivy 0.50+; Conftest 0.50+; Kyverno CLI 1.12+; Polaris 8.x; kubeconform 0.6+.
Threat Model
- Adversary 1 — Misconfigured workload running as root: A developer submits a Deployment manifest with no
securityContext. The admission controller is missing or bypassed in a dev cluster. The container runs as UID 0. A vulnerability in the application gives the attacker root-equivalent access inside the container, easing container escape. - Adversary 2 — Privileged container via chart default: A third-party Helm chart ships with
privileged: truein its default values. Nobody notices because the chart is templated and applied without scanning the rendered output. The container has host-level capabilities. - Adversary 3 — Banned image registry in production: An engineer references a public Docker Hub image in a manifest. The organisation’s supply-chain policy requires images to come from the internal registry. No CI check exists; the image reaches production pulling from an uncontrolled source.
- Adversary 4 — Policy drift between CI and admission: Admission controller policies are updated but the CI validation linter is not. Manifests pass CI checks that will fail in the cluster. Engineers lose confidence in CI validation and start bypassing it.
- Adversary 5 — Deprecated API version applied to new cluster: A manifest uses
policy/v1beta1 PodDisruptionBudget, removed in Kubernetes 1.25. The cluster is upgraded; the next deployment fails. No CI check caught the incompatibility. - Access level: Adversaries 1–3 require only the ability to merge a Kubernetes manifest or Helm chart. Adversary 4 is an operational failure. Adversary 5 requires a cluster version upgrade event.
- Objective: Run privileged workloads, supply-chain compromise, deployment failures during upgrades.
- Blast radius: A single unchecked manifest can expose a node, introduce a supply-chain vulnerability, or break a production deployment.
Configuration
Step 1: kubesec — Manifest Risk Scoring
kubesec scores individual manifests against a set of security rules, producing a numeric score with per-rule pass/fail detail. Negative score or low score fails the build.
# .github/workflows/manifest-security.yml
name: Kubernetes Manifest Security
on:
pull_request:
paths:
- 'k8s/**'
- 'charts/**'
jobs:
kubesec-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubesec
run: |
curl -sSL https://github.com/controlplaneio/kubesec/releases/download/v2.14.2/kubesec_linux_amd64.tar.gz \
| tar xz kubesec
sudo mv kubesec /usr/local/bin/
- name: Scan manifests with kubesec
run: |
FAIL=0
for manifest in $(find k8s/ -name '*.yaml' -o -name '*.yml'); do
echo "=== Scanning $manifest ==="
RESULT=$(kubesec scan "$manifest")
SCORE=$(echo "$RESULT" | jq '.[0].score')
echo "$RESULT" | jq '.[0].scoring'
if [ "$SCORE" -lt 0 ]; then
echo "FAIL: $manifest scored $SCORE (threshold: 0)"
FAIL=1
fi
done
exit $FAIL
kubesec checks that matter most:
| Rule | What it checks | Score impact |
|---|---|---|
RunAsNonRoot |
securityContext.runAsNonRoot: true |
+1 |
ReadOnlyRootFilesystem |
securityContext.readOnlyRootFilesystem: true |
+1 |
CapDropAll |
capabilities.drop: [ALL] |
+1 |
Privileged |
securityContext.privileged: true |
-30 |
AllowPrivilegeEscalation |
allowPrivilegeEscalation: true |
-7 |
HostPID |
hostPID: true |
-9 |
HostNetwork |
hostNetwork: true |
-9 |
A manifest with privileged: true scores below zero immediately, failing the build.
Step 2: Trivy — Misconfiguration Scanning
Trivy’s --scanners misconfig mode checks Kubernetes manifests against a built-in rule library covering CIS Kubernetes Benchmark, NSA guidelines, and general best practices.
trivy-misconfig:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy misconfiguration scan
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: k8s/
# Fail on HIGH or CRITICAL severity misconfigurations.
exit-code: '1'
severity: 'HIGH,CRITICAL'
format: sarif
output: trivy-results.sarif
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even if scan failed, for PR annotation.
with:
sarif_file: trivy-results.sarif
The SARIF upload causes GitHub to annotate the PR diff directly with the finding location — the developer sees the issue on the relevant line without leaving the pull request.
To scan only the manifests changed in the PR, limiting noise:
# Only scan files changed in this PR.
git diff --name-only origin/main...HEAD -- '*.yaml' '*.yml' \
| xargs -I{} trivy config --severity HIGH,CRITICAL --exit-code 1 {}
Step 3: Conftest — OPA-Based Organisational Policies
Conftest evaluates Kubernetes manifests against Rego policies. This is where team-specific rules live: required labels, approved image registries, banned capabilities, mandatory resource limits.
# policy/kubernetes/deny_latest_tag.rego
package kubernetes.deny_latest_tag
import future.keywords.contains
import future.keywords.if
# Deny containers that use the :latest image tag.
deny contains msg if {
container := input.spec.template.spec.containers[_]
endswith(container.image, ":latest")
msg := sprintf("Container '%v' uses ':latest' tag — pin to a digest or version", [container.name])
}
deny contains msg if {
container := input.spec.template.spec.initContainers[_]
endswith(container.image, ":latest")
msg := sprintf("initContainer '%v' uses ':latest' tag", [container.name])
}
# policy/kubernetes/require_labels.rego
package kubernetes.require_labels
import future.keywords.contains
import future.keywords.if
required_labels := {"app", "version", "team"}
deny contains msg if {
provided := {label | input.metadata.labels[label]}
missing := required_labels - provided
count(missing) > 0
msg := sprintf("Missing required labels: %v", [missing])
}
# policy/kubernetes/registry_allowlist.rego
package kubernetes.registry_allowlist
import future.keywords.contains
import future.keywords.if
approved_registries := {
"registry.internal.example.com",
"gcr.io/distroless",
}
deny contains msg if {
container := input.spec.template.spec.containers[_]
image := container.image
not any_approved(image)
msg := sprintf("Container '%v' image '%v' is not from an approved registry", [container.name, image])
}
any_approved(image) if {
approved_registries[registry]
startswith(image, registry)
}
# policy/kubernetes/resource_limits.rego
package kubernetes.resource_limits
import future.keywords.contains
import future.keywords.if
deny contains msg if {
container := input.spec.template.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("Container '%v' has no memory limit", [container.name])
}
deny contains msg if {
container := input.spec.template.spec.containers[_]
not container.resources.requests.cpu
msg := sprintf("Container '%v' has no CPU request", [container.name])
}
GitHub Actions step to run Conftest:
conftest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Conftest
run: |
curl -sSL https://github.com/open-policy-agent/conftest/releases/download/v0.53.0/conftest_0.53.0_Linux_x86_64.tar.gz \
| tar xz conftest
sudo mv conftest /usr/local/bin/
- name: Run Conftest against Kubernetes manifests
run: |
conftest test k8s/ \
--policy policy/kubernetes/ \
--output github \
--all-namespaces
# --output github: produces GitHub Actions annotation format;
# violations appear as inline PR comments.
The --output github flag causes Conftest to emit ::error file=...:: annotations, which GitHub Actions converts to inline PR comments pointing to the exact file and line where the policy was violated.
Step 4: Kyverno CLI — Offline Policy Testing
Kyverno runs as a Kubernetes admission controller at runtime. The Kyverno CLI replicates that evaluation locally, so the exact same policies enforced at admission are also tested in CI. This eliminates the drift between CI checks and what the cluster actually enforces.
# policy/kyverno/require-non-root.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-run-as-non-root
spec:
validationFailureAction: Enforce
rules:
- name: check-run-as-non-root
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet, Job, CronJob]
validate:
message: "Containers must not run as root. Set securityContext.runAsNonRoot: true."
pattern:
spec:
template:
spec:
securityContext:
runAsNonRoot: "true"
# policy/kyverno/disallow-privileged.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-privileged-containers
spec:
validationFailureAction: Enforce
rules:
- name: check-privileged
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet, Pod]
validate:
message: "Privileged containers are not allowed."
pattern:
spec:
template:
spec:
containers:
- =(securityContext):
=(privileged): "false"
kyverno-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Kyverno CLI
run: |
curl -sSL https://github.com/kyverno/kyverno/releases/download/v1.12.5/kyverno-cli_v1.12.5_linux_x86_64.tar.gz \
| tar xz kyverno
sudo mv kyverno /usr/local/bin/
- name: Apply Kyverno policies to manifests
run: |
kyverno apply policy/kyverno/ \
--resource k8s/ \
--detailed-results \
--table
# Exit code is non-zero if any Enforce policy fails.
Because these policies are the same YAML files deployed to the cluster, there is a single source of truth. Update the policy file, and both CI and admission control update together.
Step 5: Polaris — Best-Practices Validation
Polaris covers a broader set of Kubernetes best-practice checks beyond security: health checks, image tag pinning, resource requests, and security context completeness. It produces a scored summary and can enforce a minimum score.
polaris:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Polaris
run: |
curl -sSL https://github.com/FairwindsOps/polaris/releases/download/8.5.4/polaris_linux_amd64.tar.gz \
| tar xz polaris
sudo mv polaris /usr/local/bin/
- name: Run Polaris audit
run: |
polaris audit \
--audit-path k8s/ \
--format pretty \
--set-exit-code-below-score 80 \
--set-exit-code-on-danger
# Fails if score < 80 or any "danger" check fails.
Polaris checks include:
| Category | Example checks |
|---|---|
| Security | runAsNonRoot, readOnlyRootFilesystem, privileged, allowPrivilegeEscalation |
| Reliability | livenessProbe, readinessProbe, resource requests and limits |
| Efficiency | Resource limits set; no resource waste from oversized limits |
| Images | Not using latest tag, not pulling from insecure registries |
Step 6: Scanning Helm Chart Rendered Output
Helm charts render to Kubernetes YAML at deploy time. The rendered output is what the cluster applies — and the templates may produce quite different manifests depending on values. Always scan rendered output, not template source.
# Render and scan in one pipeline — no cluster access needed.
# Using kubesec:
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
| kubesec scan -
# kubesec reads from stdin when given '-' as the filename.
# Using Conftest (split multi-document YAML into individual documents):
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
| conftest test - --policy policy/kubernetes/ --all-namespaces
# Using Trivy:
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
> /tmp/rendered.yaml && \
trivy config --severity HIGH,CRITICAL /tmp/rendered.yaml
# Using Kyverno CLI directly against rendered output:
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
> /tmp/rendered.yaml && \
kyverno apply policy/kyverno/ --resource /tmp/rendered.yaml
GitHub Actions job scanning a Helm chart:
helm-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
- name: Render and scan Helm chart
run: |
# Render to a temp file.
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
> /tmp/rendered.yaml
echo "--- kubesec ---"
kubesec scan /tmp/rendered.yaml | jq '.[].score'
echo "--- conftest ---"
conftest test /tmp/rendered.yaml \
--policy policy/kubernetes/ \
--all-namespaces \
--output github
echo "--- kyverno ---"
kyverno apply policy/kyverno/ \
--resource /tmp/rendered.yaml \
--table
Step 7: kubeconform — Schema and API Version Validation
kubeconform validates manifests against the Kubernetes JSON schema for the target version. It catches deprecated or removed API versions, typos in field names, and structural errors that kubectl apply --dry-run would miss without cluster access.
kubeconform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubeconform
run: |
curl -sSL https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz \
| tar xz kubeconform
sudo mv kubeconform /usr/local/bin/
- name: Validate schema against target Kubernetes version
run: |
kubeconform \
-kubernetes-version 1.29.0 \
-strict \
-summary \
-output pretty \
k8s/
- name: Validate Helm rendered output schema
run: |
helm template my-release ./charts/my-app \
--values charts/my-app/values-prod.yaml \
| kubeconform \
-kubernetes-version 1.29.0 \
-strict \
-summary
-strict rejects any fields that do not exist in the schema — including custom annotation typos that would be silently ignored by the API server. Use the target Kubernetes version of the production cluster so that the check catches API removals before the cluster upgrade lands.
For clusters with custom resources (CRDs), supply schemas from the cluster:
# Export CRD schemas from the cluster for kubeconform.
kubectl get crds -o json \
| jq -r '.items[].metadata.name' \
| while read crd; do
kubectl get crd "$crd" -o json \
| jq '{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": .spec.versions[0].schema.openAPIV3Schema.properties
}' \
> "schemas/$(echo $crd | tr '.' '_').json"
done
# Run kubeconform with the exported schemas.
kubeconform \
-kubernetes-version 1.29.0 \
-schema-location schemas/ \
-strict \
k8s/
Step 8: Complete GitHub Actions Workflow
Assembling all checks into a single workflow that fails the PR with inline annotations:
# .github/workflows/manifest-security.yml
name: Kubernetes Manifest Security
on:
pull_request:
paths:
- 'k8s/**'
- 'charts/**'
- 'policy/**'
permissions:
contents: read
security-events: write # For SARIF upload.
pull-requests: write # For inline PR comments via GitHub annotations.
jobs:
schema-validation:
name: Schema (kubeconform)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubeconform
run: |
curl -sSL https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz \
| tar xz && sudo mv kubeconform /usr/local/bin/
- name: Validate schemas
run: |
kubeconform -kubernetes-version 1.29.0 -strict -summary k8s/
security-score:
name: Security Score (kubesec)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubesec
run: |
curl -sSL https://github.com/controlplaneio/kubesec/releases/download/v2.14.2/kubesec_linux_amd64.tar.gz \
| tar xz && sudo mv kubesec /usr/local/bin/
- name: Score manifests
run: |
FAIL=0
for f in $(find k8s/ -name '*.yaml'); do
SCORE=$(kubesec scan "$f" | jq '.[0].score')
[ "$SCORE" -lt 0 ] && { echo "FAIL: $f scored $SCORE"; FAIL=1; }
done
exit $FAIL
misconfig-scan:
name: Misconfigurations (Trivy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: k8s/
exit-code: '1'
severity: HIGH,CRITICAL
format: sarif
output: trivy-results.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
policy-check:
name: Policy (Conftest + Kyverno)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Conftest and Kyverno CLI
run: |
curl -sSL https://github.com/open-policy-agent/conftest/releases/download/v0.53.0/conftest_0.53.0_Linux_x86_64.tar.gz \
| tar xz && sudo mv conftest /usr/local/bin/
curl -sSL https://github.com/kyverno/kyverno/releases/download/v1.12.5/kyverno-cli_v1.12.5_linux_x86_64.tar.gz \
| tar xz && sudo mv kyverno /usr/local/bin/
- name: Conftest organisational policies
run: |
conftest test k8s/ \
--policy policy/kubernetes/ \
--output github \
--all-namespaces
- name: Kyverno admission policies
run: |
kyverno apply policy/kyverno/ \
--resource k8s/ \
--detailed-results \
--table
best-practices:
name: Best Practices (Polaris)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Polaris
run: |
curl -sSL https://github.com/FairwindsOps/polaris/releases/download/8.5.4/polaris_linux_amd64.tar.gz \
| tar xz && sudo mv polaris /usr/local/bin/
- name: Polaris audit
run: |
polaris audit \
--audit-path k8s/ \
--format pretty \
--set-exit-code-below-score 80 \
--set-exit-code-on-danger
helm-render-scan:
name: Helm Rendered Output
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
- name: Render and scan charts
run: |
for chart_dir in charts/*/; do
chart=$(basename "$chart_dir")
echo "=== Rendering $chart ==="
helm template "$chart" "$chart_dir" \
--values "${chart_dir}values-prod.yaml" \
> /tmp/rendered-${chart}.yaml
echo "--- kubesec ---"
kubesec scan /tmp/rendered-${chart}.yaml \
| jq '.[].score' | grep -v '^-' || exit 1
echo "--- conftest ---"
conftest test /tmp/rendered-${chart}.yaml \
--policy policy/kubernetes/ \
--all-namespaces \
--output github || exit 1
done
Step 9: Progressive Policy — Warn Before Enforcing
Introducing validation in CI on an existing codebase will produce hundreds of failures on day one. Use a warn-before-enforce progression to build developer trust without blocking all work.
Phase 1 — Warn only (week 1-2). Run all checks but do not fail the PR. Collect a baseline of violations.
- name: Conftest (warn only — phase 1)
run: |
conftest test k8s/ \
--policy policy/kubernetes/ \
--output github \
--all-namespaces || true # 'true' swallows exit code; never blocks PR.
continue-on-error: true
Phase 2 — Fail on new violations (week 3-4). Use a diff-based scan: only check files changed in the PR. Existing violations are not blocked; new ones are.
# Only scan manifests changed in this PR.
CHANGED=$(git diff --name-only origin/main...HEAD -- '*.yaml' '*.yml')
if [ -n "$CHANGED" ]; then
echo "$CHANGED" | xargs conftest test --policy policy/kubernetes/ --output github --all-namespaces
fi
Phase 3 — Fail on all violations (week 5+). Enable full enforcement. By this point, the baseline violations should have been remediated or granted permanent exceptions via policy annotations.
Kyverno supports validationFailureAction: Audit in the policy YAML to produce warnings without blocking. Switch to Enforce at Phase 3 — the policy files used in CI match the cluster exactly:
# policy/kyverno/require-non-root.yaml
spec:
validationFailureAction: Audit # Phase 1: warn.
# validationFailureAction: Enforce # Phase 3: block.
Because the same files are used in CI and deployed to the cluster, promoting from Audit to Enforce at CI mirrors the admission controller upgrade — no policy drift.
Step 10: Telemetry
ci_manifest_scan_violations_total{tool, severity, rule, repo} counter
ci_manifest_scan_duration_seconds{tool, repo} histogram
ci_manifest_scan_score{tool, manifest, repo} gauge
ci_policy_exceptions_total{policy, namespace, manifest} counter
ci_helm_render_scan_violations_total{chart, tool, rule} counter
Alert on:
ci_manifest_scan_violations_totalrising over a rolling 7-day window — policy compliance is regressing; audit recently merged PRs.- Any manifest reaching admission control with a violation that should have been caught in CI — indicates a scanner gap or a CI bypass.
ci_policy_exceptions_totalfor production namespaces — exceptions in production-bound manifests require security review.- Kyverno admission controller blocking a manifest that passed CI — policy drift; re-sync CI policy files from cluster.
Expected Behaviour
| Signal | Without CI validation | With CI validation |
|---|---|---|
Manifest with privileged: true submitted |
Passes PR; bounces at admission (or runs unchallenged if no admission controller) | PR fails with kubesec score below threshold; inline PR annotation |
| Helm chart renders container running as root | Undetected until runtime | helm template | conftest fails policy check in PR |
| Manifest uses deprecated API version | Silent until cluster upgrade; deploy fails | kubeconform catches version incompatibility in PR |
| Image from unapproved registry | Admitted; image pulled from external source | Conftest registry allowlist policy blocks PR |
| Admission controller policy updated without updating CI | CI passes; deploy bounces | Kyverno CLI uses same policy YAML as cluster; CI and admission in sync |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Multiple tools (kubesec + Trivy + Conftest + Kyverno) | Complementary coverage; different rule sets catch different issues | Longer CI runtime; more maintenance | Parallelise jobs; start with two tools and add others incrementally |
| Scanning rendered Helm output | Catches values-dependent misconfigurations | Requires production values to be checked in or generated | Store non-secret values in values-prod.yaml; inject secrets at deploy time |
| Kyverno CLI using cluster policy files | Zero policy drift between CI and admission | Requires policy files to be versioned with application code | Store policies in the same repo or a pinned submodule |
| Progressive warn-before-enforce rollout | Avoids big-bang PR failure on day one | Warn phase gives false sense of safety if not time-bounded | Set a calendar deadline for Phase 3 enforcement; track violation count trend |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Tool version mismatch between CI and cluster | CI passes; cluster admission rejects | Manifest reaches cluster and fails | Pin tool versions in CI workflow; match Kyverno CLI to cluster controller version |
| Conftest policy has incorrect Rego logic | Violations not detected; policy has no effect | conftest test with known-bad fixture passes unexpectedly | Add negative test fixtures (conftest verify) alongside policy files |
| kubeconform schema out of date | New API version accepted as unknown; validation skipped | Schema validation passes a removed API | Pin -kubernetes-version to the target cluster version; update on each cluster upgrade |
| Helm values differ between CI and deploy | CI scans dev values; prod values introduce violations | Production deploy bounces at admission | Always scan with the same values file used in production deploys |
| CI validation bypassed via direct push to main | Bad manifest merged without check | Admission controller fires; deploy fails | Enforce branch protection requiring CI checks to pass before merge |