Control Pull Request Delivery

Build shared GitHub and GitLab controls for review, evidence, and rollback.

Introduction

30 Second Summary

A large change can hide risk even when every check passes. A vague undo plan leaves the next operator guessing when the change fails.

In this project, you will build a shared pull request evidence contract for GitHub plus a local GitLab Community Edition lab. You will prove the contract through successful changes plus deliberate failures before releasing a private evidence handoff.

What You'll Build

Picture showing an engineer one compact release where every review decision links to evidence they can inspect.

By the end of this project, you'll have:

  • A portable policy contract you can run on both hosts to build one evidence set for a private v1.0.0 handoff.
  • Clear GitHub proof covering independent approval plus three policy failures plus NO INJECTION plus the expected GH013 refusal.
  • An honest GitLab limit demonstration where a complete change merges without approval. The empty-Rollback change remains blocked by its failed pipeline.
  • Secret Mission: Use an uninvited GitHub identity to leave one useful evidence comment on each broken pull request.

Are there any prerequisites?

You'll need a GitHub owner account plus one colleague account that can approve. You'll also need a separate uninvited GitHub account for the outside comments.

Your machine needs enough local capacity for GitLab Community Edition plus GitLab Runner containers that hold synthetic data only.

Before We Start

A change-control lab is only credible when its tools are known before evidence is collected. This starting gate prepares a repeatable local environment for every result you will inspect later.

You will commit to the project rule before preparing Bash, Git, GitHub CLI, Docker Engine, GitLab Community Edition, and GitLab Runner. The final readiness check proves that the local GitLab service is accepting requests.

In this step, get ready to:
  • Commit to the rule that keeps each change reviewable and reversible.
  • Prepare the command-line tools and authenticated identities used throughout the lab.
  • Start the disposable GitLab lab and verify its readiness endpoint.
Commit to the project rule in Bash

The project rule is Small enough to read, honest about how to undo it. Review size and rollback evidence support the same decision about whether a change is safe to accept.

What does the rule protect?

A compact change gives the reviewer a bounded problem to inspect. A written rollback gives the operator a defined response when the accepted change behaves badly.

Independent approval separates authorship from acceptance. A failed negative case proves that the control rejects a change with missing or invalid evidence.

Your confirmed Windows environment uses Windows Subsystem for Linux 2 to provide the Bash terminal required by every command in this project.

  • Press the Windows key to open the search bar.
  • Type Cursor or Visual Studio Code based on your installed editor and press Enter.

You will see your editor open with its main workspace visible.

  • Click Terminal in the top menu bar.
  • Click New Terminal.

You will see a terminal panel at the bottom of the editor.

  • Select your WSL2 profile from the terminal profile menu.
  • Record the machine and confirm the active shell by running:
printf 'MACHINE=%s\n' "$(uname -m)"
uname -a
if [ -r /etc/os-release ]; then cat /etc/os-release; else sw_vers; fi
ps -p $$ -o comm=

What does this check record?

  • The first two lines record the machine architecture and operating-system kernel.
  • The conditional line prints the operating-system release record available on the machine.
  • The final line prints the active shell process so you can confirm that every later command runs in Bash.

You should see a machine architecture and an operating-system record. The final output should identify Bash as the active shell.

Not seeing Bash?

  • Return to the terminal profile menu and select the WSL2 profile.
  • Confirm that the terminal prompt changes before rerunning the machine check.
  • Use help me activate Bash through WSL2 in my editor if the shell name still differs.
Install and verify the workstation tools

The lab depends on curl for HTTP requests, jq for JSON processing, and OpenSSL for random token generation. The minimum supported jq version is 1.6.

  • Check the installed jq version by running:
jq --version

Why check jq first?

The version result determines whether the required package set is already usable. A missing command or an older version sends you to the matching installation path below.

✔️ I see version 1.6 or higher

Your jq version meets the capability floor. Install the remaining baseline packages and refresh the existing ones from the Ubuntu repositories.

  • Install the complete baseline package set by running:
sudo apt update
sudo apt install -y git-all curl jq openssl ca-certificates wget

What does this installation add?

The package manager refreshes its repository metadata before installing the command-line tools used by the lab. The certificate and download packages support the secure GitHub CLI repository setup.

The installation should exit with status 0.

ⓧ I see an older version

Your jq command exists but does not meet the 1.6 capability floor. Refresh the Ubuntu repositories to install the supported package set.

  • Upgrade the baseline package set by running:
sudo apt update
sudo apt install -y git-all curl jq openssl ca-certificates wget

What does this upgrade do?

The first command refreshes the available package records. The second command installs or upgrades the tools required by the lab.

  • Rerun the jq version check after the installation finishes.

ⓧ Command not found

The missing command confirms that the baseline package set still needs to be installed.

  • Install jq with the rest of the baseline tools by running:
sudo apt update
sudo apt install -y git-all curl jq openssl ca-certificates wget

What does this installation prepare?

This installs Git, curl, jq, OpenSSL, certificate support, and the downloader needed for GitHub CLI. These tools support repository operations and setup automation later in the project.

  • Rerun the jq version check after the installation finishes.

GitHub CLI uses its official package repository on Ubuntu under WSL2. The repository key lets the package manager verify the downloaded software.

  • Install the current official GitHub CLI package by running:
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
  && sudo mkdir -p -m 755 /etc/apt/keyrings \
  && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
  && cat "$out" | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
  && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
  && sudo mkdir -p -m 755 /etc/apt/sources.list.d \
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
  && sudo apt update \
  && sudo apt install gh -y

What does this command do?

  • The first section makes sure the downloader is available.
  • The middle section saves the GitHub CLI repository key and package source.
  • The final section refreshes the package records and installs the GitHub CLI.

The installation should finish without a failed command. The version check later in this substep confirms that the CLI is available.

Did the package installation fail?

  • Read the final failed command in the terminal before retrying the installation.
  • Resolve the named package or repository error before continuing.
  • Use help me troubleshoot this Ubuntu package installation if the failure is unclear.

GitHub CLI authentication connects commands to the owner account used for the public drill repository. The sign-in flow may open a browser or show a device code.

  • Start the interactive GitHub.com authentication flow by running:
gh auth login

What happens during authentication?

GitHub CLI guides you through an interactive sign-in flow. Authenticate with the GitHub owner account reserved for this project.

  • Follow the sign-in prompts using the owner account.
  • Return to the Bash terminal after authentication completes.

Repository deletion is reserved for teardown. Granting the deletion scope now prevents cleanup from failing at the end of the project.

  • Confirm the active owner and grant the deletion scope by running:
gh auth status
gh auth refresh -s delete_repo

What does this authentication check prove?

The status command identifies the active GitHub account. The refresh command adds the scope required to delete the disposable public repository during teardown.

You should see the owner account as active and a successful authentication refresh.

Seeing the wrong GitHub account?

  • Stop before creating any repositories.
  • Repeat the authentication flow with the GitHub owner account.
  • Use help me correct my active GitHub CLI account if the status still shows another identity.

Git records an author name and email in every commit. Set these values in the global configuration before the repository work begins.

  • Open the global Git configuration in an editor by running:
git config --global --edit

What does this command open?

The command opens your user-level Git configuration. This is where Git reads the author identity attached to new commits.

  • Set user.name to the name you want attached to commits.
  • Set user.email to the email attached to your GitHub owner account.
  • Save the configuration file.
  • Close the configuration editor.
  • Check every workstation tool and identity by running:
git --version
curl --version
jq --version
openssl version
gh --version
gh auth status
git config user.name
git config user.email

What does the capability check prove?

  • Each version command proves that its executable is available from Bash.
  • The jq result must be 1.6 or later.
  • The authentication status must show the owner account as active.
  • The final two lines must print the Git author name and email.

Good progress. Your terminal now has the command-line tools and identities needed to create trustworthy evidence.

Start the local GitLab lab

Docker runs the local GitLab service and its runner as disposable containers. The GitLab CE image is pinned to 19.5.1-ce.0, while the runner image is pinned to v19.5.1.

  • Install Docker Engine from its official Ubuntu repository by running:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What does this installation configure?

The first section installs certificate support and saves Docker's repository key. The repository definition selects packages for the machine architecture and Ubuntu release.

The final commands install Docker Engine, its command-line client, the container runtime, and the supported build and Compose plugins.

  • Verify that the Docker server can run a container by running:
sudo docker run hello-world

What does this verification prove?

Docker pulls a small test image and starts a container from it. The output Hello from Docker! proves that the client reached a working Docker server.

That is the Docker server proof complete. The engine can now host the disposable GitLab lab.

Docker server unavailable?

  • Complete the Docker Linux post-installation steps if Docker only works with elevated permissions.
  • Open a new WSL2 terminal after changing Docker group access.
  • Rerun docker info before continuing.
  • Use help me connect WSL2 to the Docker server if the server remains unavailable.

The runner's Docker socket mount grants control over the local Docker daemon. Keep this lab isolated and use synthetic data only.

  • Create the lab network and start both pinned containers by running:
docker network create gitlab-net
docker run -d --name gitlab --network gitlab-net -p 127.0.0.1:8929:8929 -e GITLAB_OMNIBUS_CONFIG="external_url 'http://localhost:8929'" gitlab/gitlab-ce:19.5.1-ce.0
docker run -d --name gitlab-runner --network gitlab-net -v /var/run/docker.sock:/var/run/docker.sock gitlab/gitlab-runner:v19.5.1

What does this lab setup create?

  • The network gives both containers a private bridge where they can resolve each other by name.
  • The GitLab container publishes its web service only on the machine's loopback address at port 8929.
  • The runner joins the same network and receives access to the Docker socket for later pipeline jobs.

You should see one network ID followed by two container IDs.

Seeing an existing resource name?

  • Stop if Docker reports that gitlab, gitlab-runner, or gitlab-net already exists.
  • Reuse the resources only when you know they belong to an empty copy of this lab.
  • Use help me inspect these existing Docker lab resources before removing anything unfamiliar.

GitLab needs several minutes to initialize on its first start. The readiness loop waits up to ten minutes, so quiet periods and temporary 502 responses are normal during boot.

Before you run the readiness check, which HTTP status do you expect after GitLab finishes booting?

  • Wait for the GitLab sign-in endpoint to become ready by running:
i=1
while [ "$i" -le 10 ]; do
  code=$(curl -s -o /dev/null -w '%{http_code}\n' localhost:8929/users/sign_in)
  printf '%s\n' "$code"
  [ "$code" = 200 ] && break
  [ "$code" = 502 ] || exit 1
  [ "$i" -lt 10 ] || exit 1
  sleep 60
  i=$((i + 1))
done

How does the readiness loop work?

  • The loop requests the local GitLab sign-in endpoint once per minute.
  • A 502 response keeps the loop waiting because GitLab is still booting.
  • A 200 response ends the loop because the service is accepting requests.
  • Any other response stops the check so you can investigate an unexpected state.

You should see the readiness output finish with 200. You have cleared the slowest setup point, and the local GitLab service is now ready for the workspace.

GitLab did not reach 200?

  • Stop if the loop returns a status other than 502 or 200.
  • Inspect the GitLab container logs before retrying the readiness loop.
  • Use help me diagnose a GitLab container that never becomes ready if the logs do not identify a clear cause.

✔️ Awesome, I've got everything!

Your Bash workstation and disposable two-container GitLab lab are ready. The service is accepting local requests on port 8929.

ⓧ I'd like to double check the full code

This checkpoint records the exact state of the three lab resources created in this setup.

  • Running gitlab/gitlab-ce:19.5.1-ce.0 container on gitlab-net, published at 127.0.0.1:8929.
  • Docker network created for the disposable local GitLab lab.
  • Running gitlab/gitlab-runner:v19.5.1 container on gitlab-net with Docker socket mounted.

Your project rule is clear, your tools are verified, and your local GitLab lab is ready. Next, you will create the two-host workspace and push its private baseline.

Cold Start the Two-Host Workspace

Your change-control proof needs a generic workspace before you write its policy. It also needs a private GitLab baseline with an online runner.

You will save the supplied project files in $HOME/pr-drill. You will then provision the local project through the GitLab API before pushing the baseline with Git.

In this step, get ready to:
  • Create the generic workspace and its required folders.
  • Save the supplied baseline and provision the private GitLab project.
  • Initialize the Git repository and push its baseline to GitLab.
Create the generic workspace

A fixed workspace keeps every script path consistent across the two hosts. The nested folders also reserve the exact locations used by the supplied workflows and documentation.

  • Create $HOME/pr-drill and its required folders by running these commands in the active Bash terminal:
mkdir -p "$HOME/pr-drill" && cd "$HOME/pr-drill"
mkdir -p scripts docs .github/workflows .gitlab/merge_request_templates

What do these commands create?

The first command creates the pr-drill workspace in your home folder. It also makes that folder your active terminal location.

The second command creates the script, documentation, GitHub workflow, and GitLab template paths required by the baseline. These paths keep host-specific adapters separate from the shared policy.

  • Confirm the terminal is inside the new workspace by running this command:
pwd

What does this confirm?

The command prints the active folder. The path must end in /pr-drill before you save any files.

You should see a path ending in /pr-drill. Your workspace now has a predictable home.

Does the path end somewhere else?

  • Check that the first command includes $HOME/pr-drill with no spaces around the slash.
  • Run the workspace command again if the terminal stayed in another folder.
  • Help me find why my Bash terminal did not enter $HOME/pr-drill.
Save the baseline and provision local GitLab

The supplied baseline contains host adapters, automation scripts, evidence samples, and operating documents. The shared checker and two request templates remain reader-owned because you write them in the next step.

  • In the editor's file explorer, select the pr-drill folder.
  • Copy every supplied file from the full-code reference below into its matching path.
  • Save every supplied file.

Leave .github/pull_request_template.md, .gitlab/merge_request_templates/Default.md, and scripts/pr-check.sh absent. You will write those policy files from their contract in the next step.

✔️ Awesome, I've got everything!

  • Confirm the editor's file explorer lists the supplied root files, scripts, samples, and eleven files inside docs.
  • Confirm the three reader-owned files are still absent.

ⓧ I'd like to double check the full code

Compare each supplied file with the complete baseline below. Keep every path and value exact because later checks depend on them.

.gitlab.env

Why ignore this file?

The setup script stores its temporary GitLab values in .gitlab.env. Ignoring the file keeps those credentials out of commits.

* text=auto eol=lf

Why normalize line endings?

This rule stores text with line feed endings. The shared shell scripts therefore behave consistently across hosts.

variables:
  GIT_DEPTH: "0"

pr-check:
  image: alpine:3.24
  before_script:
    - apk add --no-cache git
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - TITLE="$CI_MERGE_REQUEST_TITLE" BODY="$CI_MERGE_REQUEST_DESCRIPTION" BASE="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" TRUNCATED="$CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED" sh scripts/pr-check.sh

Why this GitLab job exists

The job maps GitLab merge-request metadata into the shared checker contract. Full Git history lets the checker measure the target branch diff.

name: pr-check

on:
  pull_request:

permissions:
  contents: read

jobs:
  pr-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - name: Check pull request evidence
        env:
          TITLE: ${{ github.event.pull_request.title }}
          BODY: ${{ github.event.pull_request.body }}
          BASE: ${{ github.base_ref }}
          TRUNCATED: "false"
        run: sh scripts/pr-check.sh
      - name: Prove metadata was not executed
        if: always()
        run: test ! -e pwned && echo "NO INJECTION"

Why this GitHub workflow exists

The workflow sends GitHub pull request metadata to the same checker. Its final step proves that an injection-shaped title stayed data.

{
  "name": "main-rules",
  "target": "branch",
  "enforcement": "active",
  "bypass_actors": [],
  "conditions": {
    "ref_name": {
      "include": ["~DEFAULT_BRANCH"],
      "exclude": []
    }
  },
  "rules": [
    {
      "type": "pull_request",
      "parameters": {
        "allowed_merge_methods": ["squash"],
        "dismiss_stale_reviews_on_push": false,
        "require_code_owner_review": false,
        "require_last_push_approval": false,
        "required_approving_review_count": 1,
        "required_review_thread_resolution": false
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "required_status_checks": [{"context": "pr-check"}],
        "strict_required_status_checks_policy": false
      }
    },
    {"type": "non_fast_forward"}
  ]
}

Why this ruleset exists

The ruleset requires the shared check plus one approval. It also limits accepted changes to squash merges and blocks force pushes.

#!/bin/sh
set -eu
: "${GITLAB_HOST:?Set GITLAB_HOST before running this script}"
ROOT_TOKEN=$(openssl rand -hex 10)
docker exec gitlab gitlab-rails runner "t=User.find_by_username('root').personal_access_tokens.create(scopes: ['api','write_repository','create_runner'], name: 'pr-drill', expires_at: 7.days.from_now); t.set_token('$ROOT_TOKEN'); t.save!"
RUNNER_JSON=$(curl -fsS --request POST --header "PRIVATE-TOKEN: $ROOT_TOKEN" --data "runner_type=instance_type" --data "run_untagged=true" "http://$GITLAB_HOST/api/v4/user/runners")
RUNNER_TOKEN=$(printf '%s' "$RUNNER_JSON" | jq -er '.token')
docker exec gitlab-runner gitlab-runner register --non-interactive --url http://gitlab:8929 --clone-url http://gitlab:8929 --token "$RUNNER_TOKEN" --executor docker --docker-image alpine:3.24 --docker-network-mode gitlab-net
PROJECT_JSON=$(curl -fsS --request POST --header "PRIVATE-TOKEN: $ROOT_TOKEN" --data "name=pr-drill" --data "visibility=private" --data "shared_runners_enabled=true" --data "only_allow_merge_if_pipeline_succeeds=true" --data "squash_option=always" --data "merge_method=ff" "http://$GITLAB_HOST/api/v4/projects")
PROJECT_ID=$(printf '%s' "$PROJECT_JSON" | jq -er '.id')
ORIGIN="http://root:$ROOT_TOKEN@$GITLAB_HOST/root/pr-drill.git"
cat > .gitlab.env <<EOF
export GITLAB_HOST='$GITLAB_HOST'
export ROOT_TOKEN='$ROOT_TOKEN'
export PROJECT_ID='$PROJECT_ID'
export ORIGIN='$ORIGIN'
EOF
chmod 600 .gitlab.env
printf 'SETUP OK: project %s\n' "$PROJECT_ID"

Why this setup script exists

The script creates a temporary API token and registers the instance runner. It then creates the private project with the required merge settings.

#!/bin/sh
set -eu
: "${COLLEAGUE:?Set COLLEAGUE to the reviewer login}"
OWNER=$(gh api user -q .login)
REPO="$OWNER/pr-drill-public"
gh repo create pr-drill-public --public --source . --remote github --push
gh api -X PATCH "repos/$REPO" -F allow_squash_merge=true -F allow_merge_commit=false -F allow_rebase_merge=false -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=BLANK >/dev/null
gh api -X POST "repos/$REPO/rulesets" --input ruleset.json >/dev/null
gh api -X PUT "repos/$REPO/collaborators/$COLLEAGUE" >/dev/null
printf 'GITHUB OK\n'

Why this GitHub script exists

This later creates the disposable public repository. It applies squash-only settings plus the ruleset before inviting the independent reviewer.

#!/bin/sh
set -eu
[ "$#" -eq 2 ] || { printf 'usage: sh scripts/change.sh BRANCH LINES\n' >&2; exit 2; }
BRANCH=$1
LINES=$2
case "$LINES" in ''|*[!0-9]*) printf 'LINES must be a non-negative integer\n' >&2; exit 2;; esac
git switch main
git switch -c "$BRANCH"
awk -v lines="$LINES" 'BEGIN { for (i = 1; i <= lines; i++) print i }' > change.txt
git add change.txt
git commit -m "chore: $BRANCH"
git push -u github "$BRANCH"
git push -u origin "$BRANCH"
printf 'CHANGE OK: %s\n' "$BRANCH"

Why this change script exists

The script creates synthetic branches with exact text-line counts. It sends the same branch to both hosts for comparable evidence.

#!/bin/sh
set -eu
[ "$#" -eq 3 ] || { printf 'usage: sh scripts/open-mr.sh SOURCE TITLE FILE\n' >&2; exit 2; }
: "${GITLAB_HOST:?Run . ./.gitlab.env first}"
: "${ROOT_TOKEN:?Run . ./.gitlab.env first}"
: "${PROJECT_ID:?Run . ./.gitlab.env first}"
SOURCE=$1
TITLE=$2
FILE=$3
PAYLOAD=$(jq -n --arg source "$SOURCE" --arg title "$TITLE" --rawfile body "$FILE" '{source_branch: $source, target_branch: "main", title: $title, description: $body}')
MR_JSON=$(printf '%s' "$PAYLOAD" | curl -fsS --request POST --header "PRIVATE-TOKEN: $ROOT_TOKEN" --header "Content-Type: application/json" --data-binary @- "http://$GITLAB_HOST/api/v4/projects/$PROJECT_ID/merge_requests")
IID=$(printf '%s' "$MR_JSON" | jq -er '.iid')
printf 'MR !%s\n' "$IID"

Why this merge request script exists

The script turns a branch, title, and Markdown body into a GitLab merge request. It prints the merge request identifier needed for the evidence record.

#!/bin/sh
set -eu
. ./.gitlab.env
curl -fsS --request DELETE --header "PRIVATE-TOKEN: $ROOT_TOKEN" "http://$GITLAB_HOST/api/v4/projects/$PROJECT_ID" >/dev/null
gh repo delete "$(gh api user -q .login)/pr-drill-public" --yes
rm -f .gitlab.env
printf 'TEARDOWN OK\n'

Why this teardown script exists

The script removes the disposable projects and local environment file. The private delivery repository remains outside this cleanup path.

#!/bin/sh
set -eu
for file in .gitignore .gitattributes .gitlab-ci.yml .github/workflows/pr-check.yml .github/pull_request_template.md .gitlab/merge_request_templates/Default.md ruleset.json scripts/setup-gitlab.sh scripts/setup-github.sh scripts/change.sh scripts/open-mr.sh scripts/pr-check.sh scripts/teardown.sh sample.md norollback.md docs/teach-back.md docs/readout.md docs/plan.md docs/quickstart.md docs/runbook.md docs/decisions.md docs/architecture.md docs/operate.md docs/agents.md docs/configuration.md docs/limits.md; do
  [ -s "$file" ] || { printf 'FINAL CHECK FAIL: missing or empty %s\n' "$file" >&2; exit 1; }
done
grep -Fxq '.gitlab.env' .gitignore
grep -Fxq '* text=auto eol=lf' .gitattributes
[ "$(git check-ignore .gitlab.env)" = '.gitlab.env' ] || { printf 'FINAL CHECK FAIL: .gitlab.env is not ignored\n' >&2; exit 1; }
for script in scripts/*.sh; do sh -n "$script"; done
git diff --check
git diff --cached --check
printf 'GITLAB CASE 1: docs/readme | docs: add readme | sample.md\n'
printf 'GITLAB CASE 2: fix/rollback | fix: notes | norollback.md\n'
printf 'GENERICIZATION OK: no sensitive program name in the reviewed evidence set\n'
printf 'FINAL CHECK OK\n'

Why this final check exists

The script checks file presence, ignore behavior, shell syntax, and Git whitespace. It also records the two GitLab cases plus the genericization result.

## What
Add a numbered synthetic change.

## Why
Prove one shared pull request rule.

## How tested
Ran pr-check and recorded the host pipeline result.

## Risk
Low risk because the repository contains synthetic data only.

## Rollback
Close the request or revert its single squash commit.

## Ticket
Training drill with no external work item.

## Evidence
The host pipeline and merge history record the result.

Why this sample exists

This request body supplies evidence under all seven required headings. It becomes the checker's valid positive case.

## What
Add a numbered synthetic change.

## Why
Prove one shared pull request rule.

## How tested
Ran pr-check and recorded the host pipeline result.

## Risk
Low risk because the repository contains synthetic data only.

## Rollback

## Ticket
Training drill with no external work item.

## Evidence
The host pipeline and merge history record the result.

Why this negative sample exists

This request body leaves the Rollback section empty. It creates one controlled failure without changing any other section.

# Plan

## Rule

Small enough to read, honest about how to undo it.

## Work

1. Create generic local and public hosts.
2. Build one shared evidence contract.
3. Prove one complete and three broken GitHub pull requests.
4. Prove one clean and one broken GitLab merge request.
5. Write one handoff, release it privately, and delete the drill.

## FinOps

| Resource | Estimated cost |
|---|---:|
| Public GitHub repository and standard Actions runner | 0.00 USD |
| Local GitLab CE and GitLab Runner | 0.00 USD |
| Private GitHub delivery repository | 0.00 USD |
| Total | 0.00 USD |

Ceiling: 5.00 USD. Planned total: 0.00 USD.

## Licenses

| Tool | License |
|---|---|
| GitLab Community Edition | MIT |
| GitLab Runner | MIT |
| Git | GNU General Public License version 2.0 |
| jq | MIT |
| curl | curl |
| OpenSSL | Apache-2.0 |
| GitHub CLI | MIT |

Why the plan exists

The plan fixes the work order, cost ceiling, and license record before evidence is collected. It keeps the lab bounded.

# Readout

## Engineering evidence

| Observation | Required evidence | Actual evidence |
|---|---|---|
| GitHub complete check | PASS: title, seven sections and 20 lines | Record from run |
| Author merge attempt | Missing review refusal | Record from run |
| Approved squash | docs: add readme (#1) | Record from run |
| Empty Rollback | FAIL: section Rollback is missing or empty | Record from run |
| Injection defense | NO INJECTION and no pwned file | Record from run |
| Oversized change | FAIL: 600 lines changed, limit 400 | Record from run |
| Invalid title | FAIL: title is not a Conventional Commits title | Record from run |
| Direct force push | GH013 | Record from run |
| GitLab complete MR | Passed, merged unapproved, main one commit longer | Record from run |
| GitLab empty Rollback MR | Failed pipeline and blocked merge | Record from run |
| Secret handling | .gitlab.env ignored; token absent from .git/config | Record from run |

## Developers and change board

One sentence: The change path accepts a small, tested, reversible change only after GitHub's independent review, rejects missing rollback, excessive size, invalid title, and direct force push, and records GitLab Free's optional-approval limit from the same evidence set.

Why the readout exists

The readout gives engineering a traceable evidence table. Developers and the change board receive one sentence supported by the same rows.

# Teach-back

Answer in first person as the principal engineer who owns the shape of a change.

1. Why is 400 an inclusive review limit here?
2. Why is Rollback required even for a synthetic change?
3. Why is the GitHub author's first merge refusal valuable evidence?
4. Why does GitLab's clean unapproved merge prove a limit rather than a success control?
5. How does `env` stop the injection-shaped title from becoming shell code?
6. Why do both hosts call one checker?
7. What would make you reverse each decision in `decisions.md`?
8. Which evidence sentence do developers and the change board receive?

Why the teach-back exists

The questions test whether each control can be explained from observed evidence. They also expose decisions that need clear reversal conditions.

# Quickstart

Use Bash in `$HOME/pr-drill`.

```bash
export GITLAB_HOST=localhost:8929
sh scripts/setup-gitlab.sh
. ./.gitlab.env
git init -b main
git add -A
git commit -m "chore: start"
git remote add origin "$ORIGIN"
git push -u origin main
```

Write the two templates and `scripts/pr-check.sh`, then:

```bash
git add -A
git commit -m "ci: add pr-check"
git push
COLLEAGUE=their-user sh scripts/setup-github.sh
```

Run the complete proof in `runbook.md`, complete `teach-back.md`, then release:

```bash
sh scripts/final-check.sh
git add docs
git commit -m "docs: close-out"
git remote remove origin
git remote remove github
gh repo create pull-request-delivery --private --source . --remote delivery --push
git tag v1.0.0
git push delivery v1.0.0
gh release create v1.0.0 --generate-notes
```

Why the quickstart exists

The quickstart condenses the normal build and release path. It points back to the runbook for the full proof.

# Runbook

## GitLab returns 502

Wait one minute and retry. Stop after ten attempts. Inspect `docker logs gitlab`.

## Runner is Offline

Open Admin > CI/CD > Runners. Inspect `docker logs gitlab-runner`, network membership, and runner `config.toml`.

## GitHub check is absent

Open Actions > pr-check > run > job. Validate the workflow file and required check context `pr-check`.

## Merge works before review

This is wrong on GitHub. Inspect Settings > Rulesets > Rulesets > main-rules and confirm an empty bypass list plus one required approval.

## GitLab clean merge is blocked after PASS

Inspect the merge widget for a conflict or another merge check. GitLab Free approval is not the expected blocker.

## Empty-Rollback merge is enabled

This is wrong. Confirm the pipeline failed and the project requires a successful pipeline before merge.

## GH013 is absent

This is wrong. Confirm `non_fast_forward` is active and no bypass actor exists.

Why the runbook exists

The runbook ties each important wrong answer to a focused recovery path. It preserves the controls while problems are repaired.

# Decisions and authoritative wording

## Project rule

Small enough to read, honest about how to undo it.

## D-001: One shared checker

Choice: one POSIX checker. Alternative: two host-specific validators. Reverse if a host cannot provide equivalent title, body, base, and truncation inputs.

## D-002: Public developers' host

Choice: public throwaway repository to use free rulesets and standard Actions. Alternative: a paid private-host plan. Reverse when an eligible private plan is available.

## D-003: Local platform host

Choice: local GitLab CE. Alternative: a managed paid tier. Reverse when required approvals must be enforced on the platform host.

## D-004: Squash-only history

Choice: one main-branch commit per accepted change. Alternative: preserve branch commits. Reverse when per-commit provenance is required.

## Authoritative wording

NIST SP 800-53 CM-3, Configuration Change Control:

Determine the types of changes to the system that are configuration-controlled; review proposed configuration-controlled changes to the system and approve or disapprove such changes with explicit consideration for security and privacy impact analyses; document configuration change decisions associated with the system; implement approved configuration-controlled changes to the system; retain records of configuration-controlled changes to the system for an organization-defined time period; monitor and review activities associated with configuration-controlled changes to the system; and coordinate and provide oversight for configuration change control activities through an organization-defined configuration change control element.

NIST SP 800-53 CM-4, Impact Analyses:

Analyze changes to the system to determine potential security and privacy impacts prior to change implementation.

NIST SP 800-53 AC-5, Separation of Duties:

a. Identify and document organization-defined duties of individuals requiring separation; and b. Define system access authorizations to support separation of duties.

NIST SSDF PW.7:

Review and/or Analyze Human-Readable Code to Identify Vulnerabilities and Verify Compliance with Security Requirements (PW.7): Help identify vulnerabilities so that they can be corrected before the software is released to prevent exploitation. Using automated methods lowers the effort and resources needed to detect vulnerabilities. Human-readable code includes source code, scripts, and any other form of code that an organization deems human readable.

DORA:

Change lead time: The amount of time it takes for a change to go from committed to version control to deployed in production.

Why the decision record exists

The record connects each project choice to an alternative and reversal condition. It also preserves the authoritative control wording used by the handoff.

# Architecture

```text
Bash workspace at $HOME/pr-drill
  |
  +--> local GitLab CE --> instance runner --> alpine:3.24 --> reader checker
  |
  +--> public GitHub --> GitHub Actions --> reader checker
  |
  +--> private pull-request-delivery --> v1.0.0 evidence release
```

The two CI files adapt host metadata. The checker owns policy. Public and local drill resources are disposable. The private release is retained.

Why the architecture exists

The diagram shows one workspace feeding local, public, and retained destinations. It also makes the shared checker's role visible.

# Operate

## Start

Run `docker start gitlab gitlab-runner`, wait for HTTP 200, source `.gitlab.env`, and confirm the runner is Online.

## Observe

Use GitHub Actions logs, GitLab Build > Pipelines, GitLab Code > Commits, and both merge widgets.

## Pause

Run `docker stop gitlab-runner gitlab`.

## Recover

Use `runbook.md`. Never disable a rule to turn a wrong result green.

## Delete

Run `scripts/teardown.sh`, remove both containers, and remove `gitlab-net`. Retain only the private release.

Why the operating guide exists

The guide defines how to start, observe, pause, recover, and delete the lab. It keeps recovery separate from bypassing a control.

# Agent boundaries

- An AI client may draft prose only from the synthetic evidence set.
- A command, pipeline, merge widget, Git history, or the principal engineer decides whether a claim is true.
- Do not enter program data, credentials, client data, source from another repository, or internal names.
- Do not let an agent approve, merge, bypass, force-push, delete, or publish.
- Reject any draft that invents a run result, URL, IID, cost, license, control statement, or reviewer identity.

Why the agent boundaries exist

The boundaries limit drafting to synthetic evidence. Commands, host state, and the principal engineer remain the authorities for every claim.

# Configuration

| Surface | Setting |
|---|---|
| GitHub visibility | Public throwaway repository |
| GitHub merge method | Squash only |
| GitHub required approval | 1 |
| GitHub required check | pr-check |
| GitHub bypass actors | None |
| GitHub force push | Blocked |
| GitLab visibility | Private local project |
| GitLab merge method | Fast-forward |
| GitLab squash | Always |
| GitLab pipeline before merge | Required |
| GitLab approvals | Optional Free-tier observation |
| Maximum changed text lines | 400 inclusive |
| Binary changed lines | 0 by project decision |
| Description limit | Under 2700 characters |

Why the configuration table exists

The table makes the expected host and policy settings inspectable. It also records where the two hosts differ.

# Limits and fidelity

- GitLab Free approvals are optional and do not block merge. The clean unapproved merge proves this build-specific limit.
- One colleague proves one approval. One colleague is not a sustainable review rota. A production process needs a rota with enough available reviewers or a documented, observed exception process.
- The developers' host is a public throwaway repository because free public rulesets and standard Actions satisfy this lab. It contains synthetic data only.
- GitLab CE is the platform's own local proof. Its HTTP endpoint, root token, Docker socket, and disposable containers are lab-only choices.
- The title expression is a project profile, not the entire Conventional Commits specification.
- Binary files count as zero changed lines by decision.
- Exactly 400 text lines pass. More than 400 fail.
- The workflow does not trigger on title or body edits. Proof uses fresh pull requests with final metadata.
- `actions/checkout@v6` is a mutable major tag. A production workflow should consider a reviewed commit SHA.
- GitLab project deletion can remain pending after teardown.
- This build proves change control behavior. It does not prove a production deployment, so DORA change lead time is cited but not measured.
- Genericization search result: no program codename, agency, employer, contract, deployed product name, client name, or internal system name appears. Public tool, standard, and license names are intentional.

Why the limits record exists

The limits file separates observed behavior from broader production claims. It keeps the handoff honest about scope and fidelity.

This next action creates a temporary local access token. The script keeps it in the ignored .gitlab.env file and never prints the token by itself.

  • Provision the private GitLab project and register its instance runner by running these commands:
export GITLAB_HOST=localhost:8929
sh scripts/setup-gitlab.sh
. ./.gitlab.env
git check-ignore .gitlab.env

What does this setup prove?

The script creates the temporary ROOT_TOKEN, registers the existing runner, and creates private project root/pr-drill. The project requires a successful pipeline before merge.

Sourcing .gitlab.env loads PROJECT_ID and ORIGIN into the active shell. The final command proves Git ignores the credential-bearing file.

You should see SETUP OK: project N followed by .gitlab.env. That output proves the project exists and its local environment file is ignored.

Did the GitLab setup stop?

  • Inspect the runner container logs if registration failed.
  • Inspect the GitLab container logs if an API call failed.
  • Help me diagnose why scripts/setup-gitlab.sh failed in my local GitLab lab.
  • Open http://localhost:8929 in your browser.
  • Sign in with the local root administrator credentials for this disposable GitLab lab.
  • Select Admin from the GitLab interface.
  • Select CI/CD from the admin sidebar.
  • Select Runners to confirm the new instance runner's status.

You should see the registered instance runner marked Online. That status proves GitLab can assign pipeline work to the existing runner container.

Initialize and push the baseline

The files now need a shared starting point in version control. A single root commit gives later branches an exact baseline for their line-count comparisons.

  • Initialize main and push the baseline to the token-authenticated GitLab remote by running these commands:
git init -b main
git add -A
git commit -m "chore: start"
git remote add origin "$ORIGIN"
git push -u origin main

What does this baseline create?

Git records every supplied file in one root commit named chore: start. The origin remote uses the private project URL loaded from .gitlab.env.

The final command creates origin/main and sets it as the upstream branch. Later scripts can now push the same synthetic changes to GitLab.

You should see one root commit followed by a successful push of main. The GitLab baseline is now available to its runner.

Did the first push fail?

  • Set the missing Git author name or email if Git reports an identity error.
  • Source .gitlab.env again if ORIGIN was unavailable in the active shell.
  • Help me diagnose why my initial GitLab baseline push failed.

Before you run the final check, which three details should prove that the credential file is ignored and the baseline points to the correct private project?

  • Verify the ignored environment file and GitLab remote by running these commands in the same private terminal:
. ./.gitlab.env
git check-ignore .gitlab.env
git remote get-url origin

What does the final check show?

The first command reloads the local project values. The second prints .gitlab.env because Git excludes it.

The final command prints the token-authenticated root/pr-drill URL. Keep that terminal output private because the URL contains the temporary token.

You should see .gitlab.env and the pr-drill origin URL. You should also still see the instance runner marked Online in GitLab.

Your private host now has an online runner and a clean baseline. Next, you will write the shared evidence contract and feel why a green check still needs independent review.

Build the Evidence Contract

Your GitLab baseline is ready. Your next goal is to turn the project rule into evidence that every proposed change must carry.

One shared checker gives both hosts the same automated answer. The designed test then asks whether a green GitHub pull request can merge without an independent approval.

In this step, get ready to:
  • Define the same seven-section evidence template for both hosts.
  • Build one shared checker that enforces the evidence contract.
  • Test the automated check against the independent review gate.
Write the seven-section templates

The template gives every reviewer the same evidence map. GitHub Actions calls the shared checker with GitHub metadata. GitLab CI calls the same checker with GitLab metadata.

What evidence does each section request?

  • Use ## What to request evidence of the proposed change.
  • Use ## Why to request evidence of the reason for the change.
  • Use ## How tested to request evidence from a test or check.
  • Use ## Risk to request evidence of possible impact.
  • Use ## Rollback to request the trigger for undoing the change. Request the exact safe undo action too.
  • Use ## Ticket to request the related work record.
  • Use ## Evidence to request the logs or links that support the decision.
  • Create .github/pull_request_template.md inside the existing .github folder by using the New File control in the editor's Explorer sidebar.

You should see an empty pull_request_template.md tab under the .github folder.

  • Complete .github/pull_request_template.md by writing the seven headings above in that exact order. Place one single-line HTML comment that requests evidence directly under every heading.
  • Save .github/pull_request_template.md.

You should see exactly seven headings. Each heading should have one evidence-requesting HTML comment beneath it.

  • Create .gitlab/merge_request_templates/Default.md inside the existing .gitlab/merge_request_templates folder by using the New File control in the editor's Explorer sidebar.

You should see an empty Default.md tab under .gitlab/merge_request_templates.

  • Complete .gitlab/merge_request_templates/Default.md by copying the same heading order. Place one single-line HTML comment that requests evidence directly under every heading.
  • Save .gitlab/merge_request_templates/Default.md.

Both hosts now request the same seven pieces of evidence. The shared structure prevents one host from asking for a weaker change record.

Build the shared checker

The templates ask for evidence. The POSIX shell checker turns those requests into a portable policy that both hosts can run.

What contract must the checker enforce?

The checker reads four host-provided values: TITLE, BODY, BASE, and TRUNCATED. Start the script with set -eu so missing inputs or failed commands stop the check.

  • Check TRUNCATED first. Emit FAIL: description truncated, keep it under 2700 characters when the description is truncated.
  • Check the seven named sections second. Ignore blank lines. Ignore lines that contain only a one-line HTML comment.
  • Emit FAIL: section NAME is missing or empty for the first missing or empty section.
  • Check the title third with the Conventional Commits expression ^(feat|fix|docs|chore|refactor|test|ci|build|perf)(\([a-z0-9-]+\))?!?: .+.
  • Emit FAIL: title is not a Conventional Commits title when the title does not match.
  • Count changed text lines from Git's numeric diff for origin/$BASE...HEAD fourth. Count binary records as zero.
  • Allow exactly 400 changed text lines. Emit FAIL: N lines changed, limit 400 when the count is higher.
  • Emit PASS: title, seven sections and N lines when every check passes.
  • Create scripts/pr-check.sh inside the existing scripts folder by using the New File control in the editor's Explorer sidebar.

You should see an empty pr-check.sh tab under the scripts folder.

  • Write scripts/pr-check.sh so it implements the contract above in the stated order.
  • Save scripts/pr-check.sh.

Before you run the checker, predict whether the valid body reaches PASS or stops at a section check.

  • Test the valid evidence body against your local checker by running:
TITLE="docs: add readme" BODY="$(cat sample.md)" BASE=main TRUNCATED=false sh scripts/pr-check.sh

What does this proof send?

  • The TITLE value supplies a valid change title.
  • The BODY value reads the complete evidence body from sample.md.
  • The BASE value tells the checker to compare against main.
  • The TRUNCATED value confirms that the host supplied the complete description.

You should see an output in the form PASS: title, seven sections and N lines. Good work. Your reader-written policy now accepts the valid evidence body.

Local check stopping early?

  • Read the first FAIL line because the checker must stop in contract order. Fix only the rule named by that line.
  • Compare every section name with sample.md. Capitalization must match the seven required headings.
  • Confirm that comment-only lines do not make an otherwise empty section pass.
  • Use this prompt if the result still differs: help me compare my POSIX checker with the required validation order
Test the GitHub gate

The local PASS proves the checker on your machine. Git now needs to store the reader-owned files so the remote hosts can run the same policy.

  • Commit the reader-owned contract to main by running these commands:
git add -A
git commit -m "ci: add pr-check"
git push

What does this commit establish?

  • The staging command collects both templates plus scripts/pr-check.sh.
  • The commit records the policy as one reviewable change.
  • The push sends that policy to the existing GitLab main branch.

You should see commit ci: add pr-check followed by a successful push to GitLab.

Commit or push failing?

  • Check that both template files are saved before staging.
  • Confirm that the existing origin remote still points to the token-authenticated GitLab project.
  • Use this prompt for environment-specific help: help me diagnose why my policy commit will not push to the existing GitLab origin

The next command creates a public disposable repository. Keep every file synthetic so no program data or credentials enter the public host.

  • Replace their-user with the colleague's GitHub login.
  • Create the public repository with its ruleset by running:
COLLEAGUE=their-user sh scripts/setup-github.sh

What does the setup script configure?

The script creates public repository pr-drill-public. It pushes the current project through the github remote.

It enables squash merging. It applies main-rules with no bypass actors. It also invites the colleague.

You should see GITHUB OK when repository setup succeeds.

GitHub setup failing?

  • Confirm that their-user was replaced with the exact colleague login.
  • Confirm that GitHub CLI still shows the owner identity as active.
  • Use this prompt for authentication or permission help: help me diagnose my GitHub repository setup failure

A ruleset combines the automated check with human review requirements. Verify every control before you create evidence against it.

  • Ask the colleague to accept the invitation from github.com/notifications.
  • Click Settings in the public repository.
  • Select Rulesets in the settings sidebar.
  • Open main-rules.

You should see an active ruleset with an empty bypass list. It should require one approval plus pr-check. Only squash merging should be allowed. Force pushes should be blocked.

  • Return to Settings.
  • Select General.
  • Find the Pull Requests section.

You should see Allow squash merging enabled. Merge commits plus rebase merges should be disabled.

Ruleset value differs?

Stop if any setting differs. Correct scripts/setup-github.sh or ruleset.json before recreating the disposable repository.

Use this prompt to trace the mismatch: help me compare my GitHub settings with main-rules

  • Create the 20-line docs/readme branch on both hosts by running:
sh scripts/change.sh docs/readme 20

What does this change script create?

The script creates branch docs/readme. It writes 20 numbered text lines to change.txt.

It commits the change. It pushes the branch to both configured hosts.

You should see CHANGE OK: docs/readme after both pushes succeed.

Change branch not reaching both hosts?

  • Confirm that both github and origin still exist as remotes.
  • Confirm that the current branch started from main.
  • Use this prompt for branch or remote help: help me diagnose why scripts/change.sh did not push docs/readme to both hosts
  • Open the complete pull request with the valid evidence body by running:
gh pr create --title "docs: add readme" --body-file sample.md

What does this pull request submit?

The title matches the required project expression. The body comes from the valid seven-section sample.md file.

GitHub Actions receives that metadata through environment variables. The title remains data instead of executable shell code.

You should see the URL for the new docs/readme pull request.

Pull request not opening?

  • Confirm that docs/readme was pushed to the github remote.
  • Confirm that sample.md still contains all seven non-empty sections.
  • Use this prompt for GitHub CLI help: help me diagnose why gh pr create cannot open docs/readme
  • Click Actions in the public repository.
  • Select the pr-check workflow.
  • Open the run for the docs/readme pull request.
  • Open the pr-check job.

You should see PASS: title, seven sections and 20 lines in the checker step. The final step should show NO INJECTION.

Before you try the merge, predict whether a green automated check is enough to satisfy the complete ruleset.

  • Attempt the author's squash merge by running:
gh pr merge --squash

Why does GitHub refuse the merge?

The automated policy passed. The ruleset still requires one approving review from someone with the required repository access.

The author cannot approve their own pull request. This refusal proves that automated evidence and independent review are separate controls.

That refusal is valuable evidence. Your shared policy is green while the human separation gate remains closed.

Your evidence contract now runs on both hosts. Next, your colleague supplies the missing review before you prove each negative path.

Prove the Positive and Negative Paths

Your shared checker is green on GitHub. The refused merge proved that automated evidence alone cannot complete the change.

A colleague now supplies the independent approval required by separation of duties. You then prove each remaining GitHub rule with a controlled failure.

GitLab gives you a second host for the same policy. Its clean merge request exposes the Free-tier approval limit. Its broken merge request proves that pipeline enforcement still blocks bad evidence.

In this step, get ready to:
  • Complete the approved GitHub path with one squash commit.
  • Prove three pull request failures plus one force-push refusal on GitHub.
  • Compare an unapproved valid merge with a pipeline-blocked invalid merge on GitLab.
Prove GitHub's positive and negative paths

An approving review supplies the independent decision that the checker cannot supply. The valid pull request can move forward once your colleague records that decision.

  • Have your colleague return to the public repository from the invitation.
  • Have your colleague select Pull requests in the repository navigation.
  • Have your colleague select the open docs/readme pull request.
  • Have your colleague select Files changed.
  • Have your colleague select Review changes.
  • Have your colleague choose Approve.
  • Have your colleague select Submit review.

Before you retry the merge, consider whether the pull request now has every required human plus automated signal.

  • Retry the squash merge from your Bash terminal by running this command:
gh pr merge --squash

What does this command prove?

This retries the same merge after a different identity supplied approval. A successful result proves that the ruleset accepts the change only after its required check plus independent review are present.

The merge now completes because the missing approval exists. The accepted pull request becomes one squash commit on main.

  • Refresh your local main branch and inspect its newest commit by running these commands:
git switch main
git pull github main
git log --oneline -1

What are you checking?

  • The first line returns your working copy to main.
  • The second line downloads the accepted squash commit from the public repository.
  • The final line displays only the newest commit so the history shape is easy to inspect.

You should see docs: add readme (#1) as the newest commit. More than one new commit would contradict the squash-only rule.

That completes the valid path. Your change now has automated evidence, an independent approval, plus one compact commit.

Is the approved merge still refused?

  • Ask your colleague to confirm that the submitted review shows an approval on the current pull request revision.
  • Check that the pr-check result remains green after the review.
  • Confirm that your owner account is active before retrying the merge.
  • Help me diagnose the refused GitHub merge.

A control is stronger when you can show the exact bad input it rejects. The next three pull requests isolate rollback evidence, review size, plus title classification.

  • Create the empty-Rollback branch and its injection-shaped pull request by running these commands:
sh scripts/change.sh fix/rollback 5
gh pr create --title 'fix: test $(touch pwned)' --body-file norollback.md

What does this test isolate?

The norollback.md body contains all seven headings. Its Rollback section has no evidence.

The title resembles shell syntax, but the workflow passes it through metadata. The separate injection check proves that no pwned file was created.

Before you inspect the run, predict which policy check should fail first and whether the title text should execute.

  • Select Actions in the GitHub repository navigation.
  • Select the pr-check workflow.
  • Select the run for fix/rollback.
  • Select the pr-check job.

You should see FAIL: section Rollback is missing or empty. The final step should show NO INJECTION. No file named pwned should exist.

Did the Rollback test produce a different result?

  • Confirm that the pull request body came from norollback.md.
  • Confirm that the Rollback heading exists with no evidence beneath it.
  • Inspect the workflow environment mapping if the title text reaches the shell as executable code.
  • Help me diagnose the Rollback or injection test.
  • Create the oversized branch and its pull request by running these commands:
sh scripts/change.sh feat/big 600
gh pr create --title "feat: test large change" --body-file sample.md

What does this test isolate?

This pull request has a valid title plus complete evidence. Its synthetic text change contains 600 lines, so only the inclusive 400-line policy should reject it.

Before you inspect the new run, consider whether complete evidence can make an oversized change pass.

  • Return to the pr-check workflow from earlier.
  • Select the run for feat/big.
  • Select the pr-check job.

You should see FAIL: 600 lines changed, limit 400. A pass would mean the review-size control is missing or counting the wrong comparison.

Did the large change pass?

  • Confirm that change.txt contains 600 numbered lines on feat/big.
  • Confirm that the checker compares the branch against origin/$BASE...HEAD.
  • Help me diagnose the size-control result.
  • Create the invalid-title branch and its pull request by running these commands:
sh scripts/change.sh chore/title 5
gh pr create --title "Update stuff" --body-file sample.md

What does this test isolate?

This pull request stays small. It also carries complete evidence. Its title falls outside the required Conventional Commits expression.

Before you inspect the run, predict whether a vague title can pass when every body section is complete.

  • Return to the pr-check workflow from earlier.
  • Select the run for chore/title.
  • Select the pr-check job.

You should see FAIL: title is not a Conventional Commits title. This proves that complete evidence does not compensate for unclassified change metadata.

Did the invalid title pass?

  • Confirm that the pull request title is exactly Update stuff.
  • Confirm that the checker evaluates the title before it evaluates changed-line count.
  • Help me diagnose the title-control result.
Test the protected main branch

A pull request rule loses value if a direct update can bypass it. A non-fast-forward test checks whether the active ruleset protects main even when the owner attempts the update.

This test targets the public drill's main branch, so it deserves care. The repository contains synthetic data. The ruleset has no bypass actor.

Before you run the refusal test, predict whether repository ownership is enough to override the active rule.

  • Attempt the deliberate force push by running this command:
git push --force github HEAD:main

What does this command test?

This attempts to replace the public main branch with the current branch tip. The test succeeds as a control check only when GitHub refuses the update.

You should see a rejection containing GH013: Repository rule violations found for refs/heads/main. That refusal proves the owner cannot use this route to skip review.

Was the force push accepted?

  • Stop the drill because a successful update means main-rules is not enforcing the intended protection.
  • Inspect the ruleset to confirm that enforcement is active.
  • Inspect the bypass list to confirm that it remains empty.
  • Help me repair the missing force-push protection.
Compare GitLab's passing and failing paths

GitLab Free approvals are optional. A valid merge request can therefore pass the shared checker plus merge without approval.

The failed-pipeline requirement remains a separate control. Comparing two merge requests shows the precise boundary between those behaviors.

  • Load the GitLab API values from the ignored environment file and create the valid merge request by running these commands:
. ./.gitlab.env
sh scripts/open-mr.sh docs/readme "docs: add readme" sample.md

What do these commands do?

  • The first line loads the local GitLab host, project ID, plus API token into the current Bash session.
  • The second line creates a merge request from docs/readme with the valid title plus complete evidence body.
  • The ignored environment file keeps the token outside the committed evidence set.

The terminal should print MR !N with the merge request's numeric identifier.

Before you inspect the merge request, consider whether a green pipeline creates a required approval gate on this host.

  • Return to the local GitLab project from earlier.
  • Select Code in the left sidebar.
  • Select Merge requests.
  • Select the merge request titled docs: add readme.
  • Select Build in the left sidebar.
  • Select Pipelines.
  • Select the pipeline for the merge request.
  • Select the pr-check job.

You should see PASS: title, seven sections and N lines in the job log. The exact line count comes from the branch diff.

  • Return to the docs: add readme merge request.
  • Confirm that the merge widget offers Merge without an approval.
  • Select Merge.
  • Select Code in the left sidebar.
  • Select Commits.

GitLab main should now be exactly one commit longer. The unapproved merge is the observed Free-tier limitation.

The valid path is complete on the second host. The same checker passed, while the host supplied a different approval boundary.

Is the clean merge blocked?

  • Confirm that the merge request pipeline passed before inspecting the merge widget.
  • Inspect the merge request for a conflict or another failed merge check.
  • Confirm that no approval rule was added outside this lab's GitLab Free baseline.
  • Help me diagnose the blocked clean merge request.

The second GitLab case keeps the title valid but removes rollback evidence. This isolates whether a failed required pipeline can still stop the merge.

  • Create the empty-Rollback merge request by running this command:
sh scripts/open-mr.sh fix/rollback "fix: notes" norollback.md

What does this command test?

This reuses the fix/rollback branch with a valid title. The norollback.md body keeps the Rollback section empty, so the shared checker should reject the same evidence gap on GitLab.

The terminal should print another MR !N identifier. This identifies the new merge request without exposing the API token.

Before you inspect the pipeline, predict whether changing the title can repair missing rollback evidence.

  • Return to Code in the local GitLab project.
  • Select Merge requests.
  • Select the merge request titled fix: notes.
  • Select Build in the left sidebar.
  • Select Pipelines.
  • Select the pipeline for the empty-Rollback merge request.
  • Select the pr-check job.

You should see FAIL: section Rollback is missing or empty in the job log. This matches the first failure produced on GitHub.

Before you return to the merge widget, consider whether optional approvals should make a failed required pipeline mergeable.

  • Return to the fix: notes merge request.
  • Inspect the merge widget for the pipeline requirement.

You should see that merging is blocked by the failed pipeline. An enabled Merge action would mean the successful-pipeline requirement is missing.

Is the failed merge request still mergeable?

  • Confirm that the failed pipeline belongs to the current fix/rollback merge request.
  • Confirm that the project still requires a successful pipeline before merge.
  • Stop without merging if the merge widget remains enabled.
  • Help me repair the failed-pipeline merge gate.

You now have one accepted GitHub path plus four deliberate refusal observations across both hosts. Next, you will turn this evidence into an operating handoff and release it as v1.0.0.

Close Out and Release the Evidence

Your GitHub proof now covers the approved path plus three controlled failures. Your GitLab proof records the Free-tier approval limit plus the protection supplied by a required pipeline.

Those results still live across logs, merge pages, and local files. An operating handoff needs one evidence set that another engineer can inspect.

Credentials must stay out of the release. Internal names must also stay out of every claim.

In this step, get ready to:
  • Complete the teach-back and runtime evidence readout.
  • Validate the handoff before removing the drill remotes.
  • Publish the final source as a private evidence release.
Complete the teach-back and readout

The nine supplied operating documents already define the plan, architecture, decisions, boundaries, configuration, limits, quickstart, operations, and recovery path. The two remaining documents connect those decisions to evidence from your run.

  • In the editor file sidebar from earlier, select docs/teach-back.md.
  • Replace each prompt with your first-person answer as the principal engineer who owns the change shape.
  • Save docs/teach-back.md.
  • Review the file for one answer under each of its eight questions.

You should see eight first-person answers. Each answer should connect a project decision to the evidence that supports it.

Why use the principal-engineer voice?

The teach-back tests whether you can defend each control as the person accountable for the change shape. Your answers should explain why each decision fits this run.

A strong answer also names what evidence would make you reverse the decision. That makes the handoff useful after the lab ends.

  • Select docs/readout.md in the editor file sidebar.
  • Replace all eleven Record from run cells with evidence from this run.
  • Choose one URL, IID, log line, or explicit wrong-answer stop for each cell.
  • Preserve the existing sentence for developers and the change board.
  • Save docs/readout.md.
  • Scan the Actual evidence column for the phrase Record from run.

You should find zero remaining template cells. Every claim now points to something your run actually produced.

Why use one evidence set?

Engineering reads the detailed rows to trace each control. Developers receive a concise statement derived from those same rows.

The change board receives that same statement. One evidence source prevents the audiences from receiving conflicting claims.

Good work. Your eleven documentation files now form one traceable operating handoff.

Validate the handoff and remove sensitive remotes

The final checker tests the required files, ignored environment file, shell syntax, repository formatting, and generic wording. A passing result clears the evidence set for release.

Before you run the checker, do you expect every required artifact to pass without exposing the GitLab token?

  • Switch to main and run the final checker with these commands:
git switch main
sh scripts/final-check.sh

What does this check prove?

The first command returns the workspace to main. The second command checks every required file plus the ignored .gitlab.env state.

The script prints GITLAB CASE 1: docs/readme | docs: add readme | sample.md. It also prints GITLAB CASE 2: fix/rollback | fix: notes | norollback.md.

The final markers are GENERICIZATION OK: no sensitive program name in the reviewed evidence set plus FINAL CHECK OK.

When all four markers appear, the evidence set has passed its release check.

Final check stopped early?

  • Read the first failing line to identify the missing file, syntax problem, formatting issue, or ignore-rule problem.
  • Fix only the named problem before running the checker again.
  • Ask for help with the exact failing check.

The validated documentation now needs its own local commit. This commit becomes the source state carried into the retained handoff.

  • Commit the completed documentation by running these commands:
git add docs
git commit -m "docs: close-out"

What does this commit capture?

The first command stages the completed files under docs. The second command records them under the message docs: close-out.

This commit keeps the final evidence separate from the earlier policy work. The release can now point to one complete source state.

Git prints a new commit summary containing docs: close-out. That confirms the evidence edits are part of the local history.

No documentation commit created?

  • Confirm both edited Markdown files were saved in the docs folder.
  • Confirm every Record from run cell was replaced before retrying the commit.
  • Ask for help finding why the documentation changes are missing.

Removing remotes can feel drastic. This only removes the local connection details while both proof repositories remain available on their hosts.

  • Remove the token-bearing drill remotes and verify the token is absent by running:
git remote remove origin
if grep -F "$ROOT_TOKEN" .git/config; then echo "TOKEN LEAK"; exit 1; else echo "TOKEN NOT IN GIT CONFIG"; fi
git remote remove github

How does this protect the release?

Removing origin deletes the local remote URL that carried the GitLab token. The conditional search stops immediately if the token remains in .git/config.

Removing github disconnects the local workspace from the disposable public proof repository. The public repository remains available for the optional outside-comment challenge.

You should see TOKEN NOT IN GIT CONFIG. The ignored .gitlab.env file remains available for the later teardown.

Token still found?

  • Stop the release if the command prints TOKEN LEAK.
  • Inspect .git/config for another remote URL that contains the token.
  • Ask for help removing the token without deleting .gitlab.env.
Publish the private evidence release

A retained handoff needs a stable private destination. The GitHub CLI creates that destination directly from the validated local workspace.

The final source is paired with a Git tag plus a release. Together they give another operator a named source point to inspect.

  • Create the private handoff repository and push the final source by running:
gh repo create pull-request-delivery --private --source . --remote delivery --push

What does this command publish?

The command creates pull-request-delivery with private visibility. It uses the current folder as the source.

The --remote delivery option adds a new local remote named delivery. The --push option sends the current branch to that repository.

That gives your handoff a clean destination. GitHub prints the private repository URL after the push succeeds.

Private repository creation failed?

  • Confirm the owner account remains active in GitHub CLI.
  • Confirm a repository named pull-request-delivery does not already exist under the owner account.
  • Ask for help diagnosing the repository creation failure.

The repository now holds the final branch. A version tag identifies the exact handoff state that the release preserves.

  • Create and push the v1.0.0 tag by running:
git tag v1.0.0
git push delivery v1.0.0

Why tag the handoff?

The first command attaches v1.0.0 to the current commit. The second command sends that tag to the private delivery remote.

The tag gives the evidence set a stable version. Future commits cannot silently change what this release represents.

Git reports a new v1.0.0 tag on the delivery remote.

Tag push failed?

  • Confirm the private repository command created the delivery remote.
  • Confirm the local tag is named exactly v1.0.0.
  • Ask for help fixing the tag push.

The tag is now available on GitHub. The release adds generated notes plus a direct URL for the retained evidence handoff.

  • Create the v1.0.0 release by running:
gh release create v1.0.0 --generate-notes

What does the release add?

The command creates a GitHub release for the existing v1.0.0 tag. The --generate-notes option asks GitHub to generate the release notes.

The command prints the release URL after creation. That URL becomes the final verification surface for this step.

You should see a release URL for v1.0.0.

Release creation failed?

  • Confirm the v1.0.0 tag reached the private repository.
  • Confirm the current GitHub owner can create releases in pull-request-delivery.
  • Ask for help creating the release from the existing tag.

Before you follow the release URL, do you expect the handoff to show the same version as the tag you pushed?

  • Open the release URL printed in the terminal.
  • Confirm the release page shows v1.0.0.
  • Return to the repository page.
  • Confirm the repository is private.

That closes the loop. Your checked evidence set now has a private source repository plus a named v1.0.0 release.

Secret mission

Leave Three Outside Review Comments

Use an uninvited GitHub identity to test whether the three failed pull requests can support precise outside review. Leave one evidence-focused comment on each failure before restoring the owner identity.

Clean Up Your Resources

Clean Up Your Resources

The disposable GitHub proof costs 0.00 USD. The local GitLab proof also costs 0.00 USD.

Choose whether to keep the lab running, pause its local containers, or delete its disposable proof resources. Your private pull-request-delivery repository keeps the v1.0.0 release in every option.

Resources you used:

  • The public GitHub repository OWNER/pr-drill-public.
  • The private local GitLab project root/pr-drill.
  • The instance GitLab Runner registration.
  • The local containers gitlab and gitlab-runner.
  • The Docker network gitlab-net.
  • The ignored .gitlab.env credential file.

Keep everything running

No action is needed. Choose this option if you plan to demonstrate the evidence flow again.

  • Keep the public proof repository available for reviewing its pull requests.
  • Keep the local GitLab containers running for another demonstration.
  • Keep .gitlab.env ignored because it contains the temporary GitLab token.
  • Keep the private pull-request-delivery repository as the retained handoff.

Pause - I'll come back to this later

The local GitLab services consume memory while their containers run. Pausing them preserves the lab state for another session.

  • Pause the local GitLab services by running this command:
docker stop gitlab-runner gitlab

What Does This Command Do?

  • The command stops the runner before the GitLab service.
  • The stopped containers preserve the local lab state.
  • Confirm the terminal prints both container names.

Your local GitLab services are now paused without removing their proof data.

Containers Did Not Stop?

Check that the Docker server is running. Confirm that the container names match the names listed above.

Help me diagnose why the lab containers did not stop.

  • Resume the local GitLab services by running this command when you return:
docker start gitlab gitlab-runner

What Does This Command Do?

  • The command starts the GitLab service before its runner.
  • The existing project state becomes available again.
  • Confirm the terminal prints both container names.

Your local GitLab lab is running again with its earlier merge-request evidence intact.

Containers Did Not Start?

Check that the Docker server is available. Confirm that neither lab container was removed after the project.

Help me diagnose why the lab containers did not start.

Delete - I don't want to use this again

Deletion permanently removes the public proof repository and the disposable local GitLab state. Your private pull-request-delivery repository remains available as the handoff.

  • Return to the Bash terminal for $HOME/pr-drill from earlier.
  • Delete the disposable drill resources by running these commands:
sh scripts/teardown.sh
docker rm -f gitlab-runner gitlab
docker network rm gitlab-net

What Do These Commands Remove?

  • The scripts/teardown.sh script deletes the local GitLab project.
  • The teardown script deletes the public GitHub proof repository.
  • The teardown script removes .gitlab.env from disk.
  • The remaining commands remove both containers and the gitlab-net network.
  • Confirm the output includes TEARDOWN OK.
  • Confirm the output lists gitlab-runner.
  • Confirm the output lists gitlab.
  • Confirm the output lists gitlab-net.
  • Allow the GitLab project deletion to remain pending if GitLab reports that state.

That clears the disposable lab while preserving your private v1.0.0 handoff.

Cleanup Did Not Finish?

  • Switch the GitHub CLI back to the owner identity if the public repository deletion fails.
  • Run the teardown script again before claiming that cleanup succeeded.
  • Inspect the named Docker resource if a container or network removal fails.
  • Help me finish the failed lab cleanup.

Nice Work!

Nice Work!

Mission accomplished! Your shared pull request policy now produces reviewable evidence across GitHub and GitLab.

You've learned how to:

  • Applied one shared evidence contract to GitHub pull requests. Reused the same scripts/pr-check.sh for GitLab merge requests.
  • Proved GitHub delivery controls with one approved squash merge. Captured three distinct policy failures. Confirmed metadata stayed inert. Confirmed the force push was refused.
  • Recorded the GitLab Free limitation through a clean unapproved merge. Proved the failed empty-Rollback change remained blocked. Shipped the same evidence in the private v1.0.0 handoff.
  • Secret Mission: Used an uninvited identity to leave one precise evidence comment on each broken GitHub pull request.

Ready to quiz yourself?