Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~75 min

Lab 8: Build a GitHub Actions workflow with branch protection and required status checks

C · SimulationB · Nested virtualisation

Objectives

  • Author a `.github/workflows/ci.yml` that runs on push and pull_request events with three jobs in parallel
  • Use the `actions/checkout@v4` action pinned to a commit SHA, not a tag or branch
  • Configure a branch protection rule on `main` that requires the three status checks before merge
  • Show that a push that fails any check cannot be merged until the check passes
  • Document the gap between "CI passes locally" and "the branch is mergeable"
  • State the rule: branch protection is the only enforcement mechanism that prevents bypass

Prerequisites

Objective

By the end of this lab you will have authored a GitHub Actions workflow that runs three parallel jobs on every push and pull request, configured a branch protection rule that requires all three checks before merge, and written the human-readable policy note that explains to a frustrated engineer why their push is blocked. The point is not the YAML — anyone can write a workflow — but the policy gap: a workflow that runs but is not required by branch protection is a workflow that can be bypassed by anyone with push access.

The point of this lab is to make the bypass impossible. A workflow file in .github/workflows/ is a hint to the platform; a branch protection rule is a constraint that the platform enforces.

Architecture

A repository with a workflow file, a Terraform module, a Kubernetes manifest, and a branch protection rule that ties them together.

flowchart TB
    PR[push or pull_request to main]
    PR --> W[.github/workflows/ci.yml]
    W --> J1[job: tflint]
    W --> J2[job: terraform validate]
    W --> J3[job: kubeconform]

    J1 --> CK1[check: terraform/lint]
    J2 --> CK2[check: terraform/validate]
    J3 --> CK3[check: kubeconform]

    subgraph BP[branch protection on main]
        R1[require terraform/lint]
        R2[require terraform/validate]
        R3[require kubeconform]
    end

    CK1 -.->|required by| R1
    CK2 -.->|required by| R2
    CK3 -.->|required by| R3

    R1 --> M[merge button enabled]
    R2 --> M
    R3 --> M

The three jobs run in parallel, each producing a check named terraform/lint, terraform/validate, or kubeconform. The branch protection rule requires all three. The merge button is disabled until the three checks pass.

Requirements

  • Git 2.55.x on Linux or macOS.
  • A GitHub repository (free or paid tier; branch protection is available on both). The lab does not require write access to GitHub.com — the workflow file and the branch protection JSON are the deliverables, and either can be reviewed locally.
  • A .terraform/ directory and a Kubernetes manifest under test. Both are synthesised in Task 1; no real provider or cluster is required.
  • Optional: the gh CLI for the API examples in Task 4. The lab reads as documentation without it.

Scenario

A team has been merging pull requests without any required status checks. The policy review committee wants every merge to main to have run tflint, terraform validate, and kubeconform, and they want it impossible for an individual contributor to bypass those checks. The mitigation is two pieces: a workflow that runs the checks and reports their results as named status checks, and a branch protection rule that requires those specific check names before the merge button is enabled.

The lab walks through both pieces and produces the documentation the team needs to explain the rule to a new contributor.

Tasks

Task 1 — Build the repository and the files under test

# check-shell-blocks: allow-invalid
LAB="$HOME/protect-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

# Terraform module under test.
mkdir -p modules/network
cat > modules/network/main.tf <<'EOF'
variable "region" {
  type    = string
  default = "eu-west-1"
}

resource "local_file" "net" {
  filename = "${path.module}/network.txt"
  content  = "region=${var.region}"
}
EOF

cat > modules/network/versions.tf <<'EOF'
terraform {
  required_version = ">= 1.9.0"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}
EOF

# Kubernetes manifest under test.
mkdir -p manifests
cat > manifests/deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80
EOF

# README
cat > README.md <<'EOF'
# runbook infra

This repository is protected by branch protection rules. See
`policy-note.md` for the rationale.
EOF

git add modules/ manifests/ README.md
git commit -m 'initial: terraform module and kubernetes manifest'

The repository has two file trees that the workflow will validate: modules/network (Terraform) and manifests/ (Kubernetes YAML). Both are syntactically valid in their initial state, so the workflow should pass on the first run.

Task 2 — Write the workflow file with pinned action SHAs

# check-shell-blocks: allow-invalid
cd "$HOME/protect-lab"

mkdir -p .github/workflows

cat > .github/workflows/ci.yml <<'EOF'
name: ci

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

# Cancel any in-flight run for the same ref when a new push arrives.
concurrency:
  group: ci-${ github.ref }
  cancel-in-progress: true

jobs:
  terraform-lint:
    name: terraform/lint
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: hashicorp/setup-terraform@2f4f408a285217188937b308d9c23589e20e61d0  # v3.0.0
        with:
          terraform_version: 1.9.x
      - name: tflint
        uses: terraform-linters/setup-tflint@5d2da3a25ace5c0ddf7faf57dd6b76e23a1f3e5a  # v4.0.0
        with:
          tflint_version: latest
      - name: init
        run: terraform init -backend=false
        working-directory: modules/network
      - name: tflint
        run: tflint --init && tflint --recursive
        working-directory: modules/network

  terraform-validate:
    name: terraform/validate
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: hashicorp/setup-terraform@2f4f408a285217188937b308d9c23589e20e61d0  # v3.0.0
        with:
          terraform_version: 1.9.x
      - name: init
        run: terraform init -backend=false
        working-directory: modules/network
      - name: validate
        run: terraform validate -json
        working-directory: modules/network

  kubeconform:
    name: kubeconform
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: install kubeconform
        run: |
          curl -fsSLo kubeconform.tar.gz \
            https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz
          tar -xzf kubeconform.tar.gz
          sudo mv kubeconform /usr/local/bin/
      - name: validate manifests
        run: kubeconform -strict -summary manifests/
EOF

git add .github/workflows/ci.yml
git commit -m 'ci: add tflint, terraform validate, and kubeconform jobs'

The workflow file has three jobs, each named the same as the status check the branch protection rule requires. The actions/checkout action is pinned to a commit SHA, as are hashicorp/setup-terraform and terraform-linters/setup-tflint. The permissions: contents: read block is the principle of least privilege for the workflow’s default GITHUB_TOKEN; the workflow does not need write access to the repository.

Task 3 — Document the branch protection rule

The branch protection rule is configured on the GitHub side, but the lab produces a document describing what to configure and the JSON the API returns when it is in place.

# check-shell-blocks: allow-invalid
cd "$HOME/protect-lab"

echo "See notes below" > branch-protection-rule.md
cat <<'MARK' branch-protection-rule.md
# Branch protection rule for `main`

This rule is configured under **Settings → Branches → Branch
protection rules → Add rule**, with **Branch name pattern** set to
`main`. The JSON below is what
`gh api repos/$OWNER/$REPO/branches/main/protection` returns
when the rule is in place.

## Required settings

- **Require a pull request before merging** — disabled for solo
  development, enabled for shared branches. The lab enables it.
- **Require approvals** — set to 1 for shared branches. The lab
  leaves this at 1 for completeness.
- **Dismiss stale pull request approvals when new commits are
  pushed** — enabled, so a force-push to the PR's branch forces a
  re-review.
- **Require status checks to pass before merging** — enabled.
- **Require branches to be up to date before merging** — enabled,
  so a merge commit that lands behind the latest main is rejected.
- **Required checks** — the three jobs in `.github/workflows/ci.yml`:
  - `terraform/lint`
  - `terraform/validate`
  - `kubeconform`
- **Do not allow force pushes** — enabled. The only force-push
  allowed is to branches explicitly listed in
  **Allow specified actors to bypass pull request requirements**.
- **Do not allow deletions** — enabled.

## API response (illustrative)

```json
{
  "url": "https://api.github.com/repos/octocat/runbook/branches/main/protection",
  "required_status_checks": {
    "url": "https://api.github.com/repos/octocat/runbook/branches/main/protection/required_status_checks",
    "strict": true,
    "contexts": [
      "terraform/lint",
      "terraform/validate",
      "kubeconform"
    ],
    "contexts_url": "https://api.github.com/repos/octocat/runbook/branches/main/protection/required_status_checks/contexts"
  },
  "required_pull_request_reviews": {
    "url": "https://api.github.com/repos/octocat/runbook/branches/main/protection/required_pull_request_reviews",
    "dismiss_stale_reviews": true,
    "require_code_owner_reviews": false,
    "required_approving_review_count": 1
  },
  "restrictions": null,
  "required_linear_history": false,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "block_creations": false,
  "required_conversation_resolution": true,
  "lock_branch": false,
  "allow_fork_syncing": false
}

How to configure it via the API

The rule can be configured in one PUT call:

gh api \
  --method PUT \
  -H "Accept: application/vnd.github+json" \
  /repos/$OWNER/$REPO/branches/main/protection \
  --input protection-payload.json

Where protection-payload.json contains the same fields as the response above (the API accepts the response shape as the request shape for PUT).

Why each setting matters

  • Strict status checks — a check that ran against an older commit does not satisfy the requirement. Without strict, a contributor can push a change that breaks CI but claims an old green run as proof.
  • No force pushes — a force-push bypasses history. The branch protection rule treats force-push as a write that does not need a PR, and a force-push on main is the failure mode the rule exists to prevent.
  • No deletions — a branch deletion removes the audit trail for every PR ever merged into it.
  • Conversation resolution — a PR with unresolved review comments cannot merge. This is the “don’t ignore code review” guarantee. MARK

git add branch-protection-rule.md git commit -m ‘docs: describe branch protection rule for main’


The branch protection rule is documented in prose, in JSON, and
in the exact API call that creates it. A new contributor who asks
"why is my push blocked?" can be pointed at this document.

### Task 4 — Simulate the failure mode

The lab simulates what happens when a push fails a required check.
The simulation is a text file, not a live API call.

```bash
# check-shell-blocks: allow-invalid
cd "$HOME/protect-lab"

cat > merge-blocked-screenshot.txt <<'EOF'
# Simulated output of `gh pr merge` against a PR with a failing check

$ gh pr merge 42 --squash
X Pull request 42 is not mergeable: 1 required status check is failing or pending.

Required status checks:
  X terraform/lint — Expected — Waiting for status to be reported
  ✓ terraform/validate — Successfully completed in 1m 23s
  ✓ kubeconform — Successfully completed in 0m 9s

Review the failing check before retrying the merge:
  https://github.com/$OWNER/$REPO/actions/runs/<run-id>

# Simulated output of `gh pr view --json mergeable`

$ gh pr view 42 --json mergeable,mergeStateStatus
{"mergeable":false,"mergeStateStatus":"BLOCKED"}
EOF

git add merge-blocked-screenshot.txt
git commit -m 'docs: simulated merge-blocked output'

The simulated output is what a contributor would see if they attempted to merge a PR whose CI is failing. The merge button on the GitHub UI is disabled, and gh pr merge refuses with a non-zero exit code and a message that names the failing check.

Task 5 — Write the policy note

The policy note is the human-readable explanation of the rule. It is what an engineer reads when they ask “why is my push blocked?”.

# check-shell-blocks: allow-invalid
cd "$HOME/protect-lab"

cat > policy-note.md <<'EOF'
# Policy note: why every merge to `main` runs three checks

The `main` branch of this repository is protected by a branch
protection rule that requires three status checks to pass before a
pull request can merge:

- `terraform/lint` — `tflint` over every Terraform file under
  `modules/`.
- `terraform/validate` — `terraform validate` over the same.
- `kubeconform` — strict schema validation over every YAML file
  under `manifests/`.

A merge is only possible when all three checks have reported
**success** against the latest commit on the PR's branch. If any
check is failing, pending, or stale, the merge button is disabled
and `gh pr merge` refuses with a non-zero exit code.

## Why these three checks

1. **`tflint`** catches provider-specific anti-patterns and unused
   declarations that `terraform validate` does not. It is the
   cheapest possible static analysis for Terraform code.
2. **`terraform validate`** confirms the configuration is parseable
   and the variables and outputs reference real types. It does not
   contact any cloud provider.
3. **`kubeconform`** validates Kubernetes manifests against the
   upstream OpenAPI schemas for the API versions declared in the
   YAML. It catches `apiVersion: apps/v1` typos and schema-level
   errors that a YAML linter misses.

The three together cover the most common failure modes for a
hybrid Terraform + Kubernetes change. None of them require cloud
credentials, which means they run in any environment with no
secrets exposed.

## Why this is enforced by the platform, not by convention

A workflow file in `.github/workflows/` is a hint to the GitHub
Actions platform: it says "run these checks on these events". It
is **not** an enforcement mechanism. A contributor with push
access could remove the workflow file, or rename the jobs, or
disable the workflow, and the checks would no longer run. The
branch protection rule is the only enforcement mechanism, because
it is the only setting that prevents the merge button from being
clicked.

The team policy is "the branch protection rule is the source of
truth, and the workflow file is a target". If the workflow file
drifts from what the rule expects, the rule wins. If the rule is
removed, the team is in violation of the security review's
finding, regardless of what the workflow file says.

## What to do when your merge is blocked

1. Open the PR's checks tab and identify which check is failing.
2. Read the failing check's logs. The error is typically a
   specific line and column.
3. Either fix the code so the check passes, or update the check
   itself if the failure is a false positive (and ask the team
   to confirm the change).
4. Do **not** bypass the check. The bypass path is "ask a
   maintainer to disable the rule for this PR", and that request
   will be declined unless the rule is genuinely wrong.

## What to do when you want to add a new check

1. Add the job to `.github/workflows/ci.yml`.
2. Push the change as a PR.
3. Once the PR is approved and merged, add the new check's name
   to the branch protection rule's required-checks list.
4. The next merge will require the new check.

The order matters: the check must run successfully on the PR
that adds it to the rule, otherwise the rule update is rejected
("Required status check <name> is not expected").
EOF

git add policy-note.md
git commit -m 'docs: policy note for required status checks'

The policy note is the durable artefact. The workflow file and the branch protection rule are both configuration; the policy note is the explanation of why the configuration exists, and it is what a new contributor reads on day one.

Task 6 — Capture the workflow file as a deliverable

cd "$HOME/protect-lab"

# The workflow file is already in the repository, but copy it to
# the home directory as a deliverable that does not depend on the
# repository state.
cp .github/workflows/ci.yml "$HOME/ci.yml"

The deliverable is the workflow file in isolation. Reviewers can read it without checking out the repository.

Task 7 — Verify the YAML is syntactically valid

cd "$HOME/protect-lab"

# YAML parse check. Python is the easiest to invoke.
python3 -c "
import sys, yaml
with open('.github/workflows/ci.yml') as f:
    doc = yaml.safe_load(f)
print('on:', list(doc[True].keys()) if True in doc else list(doc['on'].keys()))
print('jobs:', list(doc['jobs'].keys()))
"

# Or, if Python is not available:
ruby -ryaml -e '
doc = YAML.load_file(".github/workflows/ci.yml")
puts "jobs: #{doc["jobs"].keys.join(", ")}"
'

The YAML must parse without errors, and the jobs keys must be exactly terraform-lint, terraform-validate, and kubeconform. If any of them is missing, the workflow file has a structural error and the branch protection rule will not be able to require the corresponding check.

Validation

  • .github/workflows/ci.yml exists, parses as valid YAML, and has exactly three jobs: terraform-lint, terraform-validate, and kubeconform.
  • Every uses: reference in the workflow is a 40-character commit SHA, not a tag or branch reference.
  • branch-protection-rule.md describes the required settings in prose and includes a JSON response shape with the three check names.
  • merge-blocked-screenshot.txt simulates a gh pr merge refusal with at least one failing check named.
  • policy-note.md covers the three checks, the rationale for enforcement by the platform, and the recovery path for a blocked merge.
  • The deliverables .github/workflows/ci.yml, branch-protection-rule.md, merge-blocked-screenshot.txt, and policy-note.md exist and are non-empty.

Expected Outcome

A repository whose main branch is protected by a documented rule, with a workflow file that produces the checks the rule requires.

$HOME/protect-lab/
├── .github/workflows/ci.yml       # the workflow file, pinned to SHAs
├── modules/network/                # terraform module under test
│   ├── main.tf
│   └── versions.tf
├── manifests/deployment.yaml       # kubernetes manifest under test
├── branch-protection-rule.md       # the rule, in prose and JSON
├── merge-blocked-screenshot.txt    # simulated gh pr merge refusal
├── policy-note.md                  # the human-readable explanation
└── README.md

A push to a real instance of this repository would run the three checks, report their results as named status checks, and only allow a merge when all three pass.

Troubleshooting

The branch protection rule does not list the check even though the workflow ran. The check name in the GitHub UI is the value of name: on the job, not the jobs.&lt;id&gt; key. Re-confirm with gh api repos/$OWNER/$REPO/commits/&lt;sha&gt;/check-runs and look at the name field. Update the workflow’s name: field to match the rule’s required name, or update the rule to match the workflow’s name:.

The merge button is enabled but the latest push has not been checked. The branch protection rule has strict: true (the “Require branches to be up to date” setting in the UI), and the push is behind main. Either rebase the branch or update the rule to disable strict mode. Strict mode is the production default; disabling it should require a security review.

The workflow runs but permissions: contents: read does not appear to take effect. The default GITHUB_TOKEN permissions have changed over time; on repositories created before February 2023, the default is read-write. Add the permissions: block at the workflow level (not the job level) to override the default. The lab’s workflow has it at the workflow level.

actions/checkout@b4ffde65... errors with “reference not found”. The SHA you pinned is no longer present in the upstream repository. Re-pin to the current SHA: visit the action’s @tagged-release commit history and copy the SHA of the release you want to pin. Update all references in the workflow file.

The terraform/setup-terraform action errors with “terraform not found”. The action’s input terraform_version accepts a version range like 1.9.x. If you pass latest, the action errors. The lab uses 1.9.x deliberately to avoid the “we pinned to a version that no longer exists” failure mode.

Cleanup

LAB="$HOME/protect-lab"

# Keep the deliverables.
mv "$LAB"/policy-note.md "$LAB"/branch-protection-rule.md \
   "$LAB"/merge-blocked-screenshot.txt "$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/ci.yml" "$HOME/ci.yml" 2>/dev/null

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'protect-lab' -print
# expected: (no output)

# Note: the global git config from Lab 7 (gpg.format, user.signingkey)
# persists. If you want to undo it:
git config --global --unset gpg.format
git config --global --unset user.signingkey

If you applied the branch protection rule to a real GitHub repository during the lab, remove it through the GitHub UI or the API:

gh api --method DELETE \
  /repos/$OWNER/$REPO/branches/main/protection

What You Learned

  • A workflow file is a hint, not enforcement. The branch protection rule is what makes the checks required; the workflow file is what produces the checks.
  • Check names must match exactly. The name: field on the job is what appears in the branch protection rule. A typo is the most common reason “the check ran but the branch is still mergeable”.
  • Pinned action SHAs are the production default. Tags can move; branches can be force-pushed; commit SHAs are immutable. The verbosity of full-SHA pinning is the price of supply-chain safety.
  • strict: true is the right default. A check that ran against an older commit should not satisfy the requirement. Disabling strict mode is a security finding, not a preference.
  • The policy note is the durable artefact. The workflow file is configuration; the policy note is the explanation. A new contributor reads the policy note on day one; the workflow file is read by the platform.
  • The bypass path is “ask a maintainer to disable the rule”, and that request is normally declined. The branch protection rule is the only mechanism that prevents bypass; a request to disable it should be reviewed by the team rather than approved automatically.
  • The API is the authoritative source of truth. The GitHub UI is a wrapper around the same API; configuring the rule through gh api --method PUT produces a JSON that can be reviewed in a pull request, which is what makes the rule auditable.

Deliverables

  • · .github/workflows/ci.yml — the workflow file, pinned action SHAs, three parallel jobs
  • · branch-protection-rule.md — the human-readable description of the rule and the JSON GitHub returns from the API
  • · merge-blocked-screenshot.txt — the simulated output of a `gh pr merge` attempt against a failing check
  • · policy-note.md — the rationale for required checks, written for an engineer who is asking "why is my push blocked?"

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.