Govern GitFlow Releases in GitLab

Build protected GitLab releases with version checks and evidence handoff.

Introduction

30 Second Summary

Release pressure makes shortcuts tempting. Without a clear trail, nobody can prove where a shipped change came from or whether the next release includes it.

In this project, you will build a disposable GitLab lab that enforces this rule: Cut the version, then the branch. You will use GitFlow evidence to govern a release from local bootstrap through private handoff.

What You'll Build

You will demo a private release graph where v1.4.1 produces production evidence before returning to develop.

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

  • Traced a six-row release map from v1.3.9 through release 1.4.0 and hotfix 1.4.1.
  • Run a five-outcome checker that exposes malformed branches, version mismatches, and reused tags. You will also prove that protected refs, off-main production, and missing back-merges reject unsafe paths.
  • Published a private handoff in GitHub and Linear. Its eight documentation pages reuse one evidence set across two readouts.
  • Secret Mission: Reconstruct the release graph from evidence. Defend every extra GitFlow merge without changing any control.

Are there any prerequisites?

You need a Windows device with Windows Subsystem for Linux 2 plus at least 8 GB available to the guest. You also need Cursor, a GitHub account, and a Linear workspace.

Before We Start

A shared release rule keeps every later decision tied to the same control story. This checkpoint records the scope you are committing to before the lab begins.

Run Day Zero Stop Checks

Every release claim depends on evidence from a known machine, shell, toolchain, and account. A failed Day Zero check makes the later evidence unreliable.

This gate prepares Cursor with a Windows Subsystem for Linux 2 Bash terminal. It also prepares the disposable GitLab lab that enforces your release controls.

In this step, get ready to:
  • Confirm that every required command-line tool meets its pinned version.
  • Confirm access to the private GitHub and Linear handoff accounts.
  • Start GitLab Community Edition and register a GitLab Runner.
Verify the toolchain and account access

The toolchain is your first evidence boundary. One missing executable or wrong version is a stop condition.

What does each tool support?

  • Git manages the branches, merge commits, and tags in the release graph.
  • Go runs the hotfix regression test.
  • Docker Engine hosts the disposable GitLab services.
  • curl sends requests from the supplied GitLab API scripts.
  • jq reads identifiers from GitLab API responses.
  • GitHub CLI publishes the private handoff.
  • Press the Windows key to open Windows search.
  • Type Cursor into the search field.
  • Press Enter to start Cursor.
  • Create an integrated terminal using Cursor's terminal controls.
  • Choose your WSL 2 Bash profile for the terminal.
  • Print the required versions and inspect GitHub authentication by running these commands:
git --version
go version
docker --version
curl --version
jq --version
gh --version
gh auth status

What do these checks prove?

Each version command proves which executable Bash resolves from its search path. The final command checks whether GitHub CLI has stored authentication.

Continue only when every tool prints successfully. Go must be version 1.26 or later.

Compare the output with the required versions before you continue.

✔️ I see every required version

Your output shows Git 2.56.0. It also shows Go 1.26 or later.

Docker Engine shows 29.9.2. curl shows 8.23.0.

jq shows 1.8.2. GitHub CLI shows 2.103.3.

Good start. Your WSL 2 toolchain now matches the lab contract.

ⓧ I see an older version

An older executable can change command behavior or output. Upgrade each mismatched tool before continuing.

Still seeing an older version?

Your shell may still resolve an older executable from its search path. Close the old terminal before checking again.

Use this if the mismatch remains: Help me trace the older executable.

ⓧ A command is not found

A missing command means the tool is unavailable inside your WSL 2 environment. Install each missing tool before continuing.

Command still missing?

Confirm that you installed the Linux build inside WSL 2. A Windows installation does not automatically add the executable to the WSL 2 search path.

Use this if the command remains unavailable: Help me diagnose the missing command.

The result from gh auth status determines whether GitHub CLI can use your account.

✔️ GitHub authentication is active

GitHub CLI reports an active account. Your private repository handoff path is ready.

ⓧ GitHub authentication is missing

The login command starts an interactive authentication flow. It asks for an account host before opening browser authentication.

  • Start GitHub authentication by running this command:
gh auth login

What does this command do?

The command connects GitHub CLI to your GitHub account. It stores authentication for later repository commands.

  • Choose GitHub.com when the wizard asks for the account host.
  • Choose HTTPS when the wizard asks for the Git protocol.
  • Approve the authentication request in your browser.
  • Return to the Cursor terminal after the browser confirms access.
  • Verify the stored authentication by running this command:
gh auth status

What does this check prove?

The command reads the authentication stored by GitHub CLI. A listed active account proves that the login completed.

You should now see an active GitHub account in the terminal.

Authentication did not complete?

Restart the login flow if the browser approval expired. Confirm that you return to the same WSL 2 terminal afterward.

Use this if GitHub CLI still reports no account: Help me complete GitHub CLI authentication.

The retained handoff also uses a Linear workspace. Confirm access now so account recovery does not interrupt the shipping step.

  • Sign in to your Linear workspace using your browser.
  • Open Projects to confirm that the workspace loads.

You can now reach the workspace that holds the later handoff records.

Start GitLab and create the root token

GitLab Community Edition provides the protected refs and pipeline controls for this disposable lab. This local Docker-on-Windows host is for rehearsal only.

Why use a disposable local lab?

The lab gives you a real control surface without provisioning cloud infrastructure. The Windows Docker host remains outside GitLab's production support boundary.

Eight GB is a constrained installation. The documented single-node baseline is 16 GB.

  • Create the isolated gitlab-net bridge network by running this command:
docker network create gitlab-net

What does this command do?

The Docker bridge network gives the GitLab and Runner containers a private route. Containers on this network can resolve each other by name.

You should see gitlab-net printed in the terminal.

Network creation failed?

Docker Engine may be stopped. Your WSL 2 account may also lack access to the Docker socket.

An existing disposable network can be reused. Use this for any other failure: Help me diagnose the network error.

The GitLab image download is 1.54 GB. The first start can remain busy for several minutes while GitLab initializes its services.

  • Start GitLab Community Edition 19.5.1 on the loopback interface by running this command:
docker run -d --name gitlab --network gitlab-net -p 127.0.0.1:8929:8929 --shm-size 256m -e GITLAB_OMNIBUS_CONFIG="external_url 'http://localhost:8929'" gitlab/gitlab-ce:19.5.1-ce.0

What does this command do?

  • The container runs the pinned GitLab Community Edition image.
  • The port mapping exposes GitLab only through the host loopback interface.
  • The shared-memory setting gives the container 256 MiB.
  • The external URL configures browser access through http://localhost:8929.

You should see a long container identifier. GitLab then begins its internal initialization.

GitLab container did not start?

A stopped Docker daemon can block the container. An existing container named gitlab can also block it.

Port 8929 must be available on your machine. Help me diagnose the GitLab container.

  • Enter http://localhost:8929 in your browser address bar.
  • Wait for the GitLab sign-in page to load.
  • Switch back to the Cursor terminal after the sign-in page loads.
  • Retrieve the initial root password by running this command:
docker exec gitlab grep 'Password:' /etc/gitlab/initial_root_password

What does this command do?

The command reads the initial password from the running GitLab container. You use it once to enter the root administrator account.

Protect the administrator credentials

The initial password and root token control this disposable GitLab instance. Store both values in a password manager.

Keep the values out of screenshots, shell variables, and project files during this step.

  • Copy the value after Password: into your password manager.
  • Return to the GitLab sign-in page.
  • Enter root in the username field.
  • Paste the initial password into the password field.
  • Click Sign in.

The GitLab home page now loads under the root administrator account.

  • Click your avatar in the GitLab header.
  • Click Edit profile.
  • Select Access.
  • Select Personal access tokens.
  • Click Generate token.
  • Select Legacy token.

Why use these token scopes?

The api scope lets the supplied scripts create GitLab resources through the API. The write_repository scope supports later Git-over-HTTP writes.

  • Enter a name that identifies the disposable branching-model lab.
  • Enable the api scope.
  • Enable the write_repository scope.
  • Submit the form to create the token.
  • Copy the revealed token into your password manager.

That sensitive setup is complete. Your root Legacy token now has the two scopes required by the supplied automation.

Token options missing?

Confirm that you are signed in as the root administrator. Confirm that you selected the Legacy token workflow.

Do not continue with a token that lacks either scope. Help me find the required token options.

Register the GitLab Runner

GitLab Runner executes pipeline jobs through a Docker executor. Its job containers use Alpine Linux on the same private network as GitLab.

  • Open the GitLab administrator area.
  • Select CI/CD.
  • Select Runners.
  • Click Create instance runner.
  • Choose Linux.
  • Enable Run untagged.
  • Create the instance runner.
  • Copy the registration token beginning with glrt- into your password manager.

Use the registration token once

The registration token is a credential. The next command uses a plain placeholder so the learning platform does not store its value.

Keep the token out of screenshots. Remove it from the shell environment immediately after registration.

  • Start the pinned Runner manager on gitlab-net by running this command:
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 command do?

  • The container runs GitLab Runner 19.5.1.
  • The network connection lets the Runner resolve the GitLab container by name.
  • The socket mount lets the Runner create job containers through the local Docker daemon.

You should see a container identifier. The Runner manager is now ready to receive its registration configuration.

  • Replace paste-the-glrt-token-once with the registration token you copied.
  • Register the Runner against the GitLab container by running these commands:
export RUNNER_TOKEN='paste-the-glrt-token-once'
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
unset RUNNER_TOKEN

What does this registration do?

  • The first line holds the registration token for the current Bash session.
  • The registration command connects through http://gitlab:8929.
  • The Docker executor starts jobs with alpine:3.24.
  • The network mode places job containers on gitlab-net.
  • The final line removes the token from the current shell environment.

The terminal should print Runner registered successfully. A registration failure is a stop condition.

Runner registration failed?

The copied token may contain an extra space. The Runner container must also remain attached to gitlab-net.

Stop until registration succeeds. Help me diagnose the registration failure.

Before you refresh, do you expect GitLab to show the Runner as Online or Offline?

  • Return to the GitLab Runners page.
  • Refresh the page.
  • Check the status shown for the new Runner.

✔️ The Runner is Online

The Runner is registered and connected. It can now accept untagged jobs from this GitLab instance.

ⓧ The Runner is Offline

An Offline status is the only Day Zero rehearsal exception. Every later pipeline runtime claim must be marked NOT REHEARSED until the Runner becomes Online.

  • Confirm that the gitlab-runner container is still running.
  • Confirm that the registration command printed Runner registered successfully.
  • Record later pipeline output as NOT REHEARSED if the status remains Offline.

Runner remains Offline?

The Runner may still be reloading its registration configuration. Refresh the page after the container has remained running.

Use this if the status does not change: Help me diagnose the Offline Runner.

Use this checkpoint to compare the three Docker artifacts created in this step.

✔️ Awesome, I've got everything!

Your network and both containers match the required Day Zero state. The local lab is ready for the cold-start files.

ⓧ I'd like to double check the full code

Compare your lab with these exact target states.

Running gitlab/gitlab-ce:19.5.1-ce.0 with loopback port 127.0.0.1:8929:8929 and 256 MiB shared memory.

Running gitlab/gitlab-runner:v19.5.1, registered with a Docker executor using alpine:3.24 and gitlab-net.

Created local Docker bridge network for the disposable GitLab CE and Runner lab.

What does this checkpoint cover?

The first line describes the GitLab container. The second line describes the registered Runner.

The final line confirms the bridge network that connects the disposable services.

Your Day Zero record now separates observed checks from any NOT REHEARSED pipeline claims. Next, you will seed the lab before applying the protections it must enforce.

Build and Protect the Lab

Day Zero proved that your Cursor terminal can reach the local GitLab instance. Your GitLab Runner is ready to execute pipeline jobs.

The release map must reach develop before its protections take effect. This order gives you a working baseline without bypassing the controls you are about to enforce.

In this step, get ready to:
  • Seed the service at version 1.3.9 on main plus develop.
  • Author the four release-governance files on develop.
  • Protect main plus develop plus the v* tag pattern.
Seed the service before protection

The seed commit establishes the shared history for every later release check. It also gives the back-merge report a known starting tag at v1.3.9.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

This places the new lab in your Desktop directory. You can find the project easily after leaving the terminal.

  • Create the branching-model folder by running these commands:
mkdir branching-model
cd branching-model

What do these commands do?

The first command creates the local repository folder. The second command moves your terminal into it.

  • Create every supplied file shown in the double-check tab inside your branching-model folder.
  • Preserve each path shown in the code block filename.
  • Save every supplied file in Cursor.

✔️ Awesome, I've got everything!

Great. Confirm that every supplied file is saved inside branching-model before you bootstrap GitLab.

ⓧ I'd like to double check the full code

* text=auto eol=lf

Why this file exists

This file normalizes repository text files to line feed endings. The shell scripts keep consistent endings across operating systems.

.gitlab.env

Why this file exists

This rule keeps generated GitLab credentials outside version control. The later ignore check proves that protection remains active.

1.3.9

Why this file exists

This file carries the service version used by the release checker. The seed starts at 1.3.9.

module example.com/branching-model

go 1.26

Why this file exists

This file defines the Go module. It also sets Go 1.26 as the language version.

package main

import (
	_ "embed"
	"net/http"
)

//go:embed VERSION
var version string

func healthHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }
func versionHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) }

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/version", versionHandler)
	panic(http.ListenAndServe("127.0.0.1:8080", mux))
}

Why this file exists

The service embeds VERSION at build time. Its two routes expose health plus version responses for later testing.

#!/bin/sh
set -eu
: "${GITLAB_HOST:?set GITLAB_HOST to host:port without a scheme}"
: "${ROOT_TOKEN:?set ROOT_TOKEN to a root administrator token with api scope}"
api="http://$GITLAB_HOST/api/v4"
newline='
'
api_request() {
  method=$1; url=$2; shift 2
  if ! response=$(curl --silent --show-error --request "$method" --header "PRIVATE-TOKEN: $ROOT_TOKEN" --write-out '\n%{http_code}' "$@" --url "$url"); then
    printf 'API ERROR: status 000\ncurl transport failure\n' >&2; exit 1
  fi
  status=${response##*"$newline"}; body=${response%"$newline"*}
  case "$status" in 2??) return 0 ;; *) printf 'API ERROR: status %s\n%s\n' "$status" "$body" >&2; exit 1 ;; esac
}
api_request POST "$api/projects" --data-urlencode 'name=branching-model' --data-urlencode 'path=branching-model' --data 'visibility=private'
PROJECT_ID=$(printf '%s\n' "$body" | jq -er '.id')
api_request POST "$api/users" --data-urlencode 'username=dev-ana' --data-urlencode 'name=Dev Ana' --data-urlencode 'email=dev-ana@example.test' --data 'force_random_password=true' --data 'skip_confirmation=true'
DEV_ID=$(printf '%s\n' "$body" | jq -er '.id')
api_request POST "$api/projects/$PROJECT_ID/members" --data-urlencode "user_id=$DEV_ID" --data 'access_level=30'
api_request POST "$api/users/$DEV_ID/impersonation_tokens" --data-urlencode 'name=branching-model-write-repository' --data 'expires_at=2027-10-29' --data 'scopes[]=write_repository'
DEV_TOKEN=$(printf '%s\n' "$body" | jq -er '.token')
ORIGIN="http://root:$ROOT_TOKEN@$GITLAB_HOST/root/branching-model.git"
DEV="http://dev-ana:$DEV_TOKEN@$GITLAB_HOST/root/branching-model.git"
umask 077
{
  printf "export GITLAB_HOST='%s'\n" "$GITLAB_HOST"
  printf "export ROOT_TOKEN='%s'\n" "$ROOT_TOKEN"
  printf "export PROJECT_ID='%s'\n" "$PROJECT_ID"
  printf "export DEV_ID='%s'\n" "$DEV_ID"
  printf "export ORIGIN='%s'\n" "$ORIGIN"
  printf "export DEV='%s'\n" "$DEV"
} > .gitlab.env
printf 'SETUP OK: project %s, dev-ana is Developer\n' "$PROJECT_ID"

What does this script do?

The script creates the private project plus the synthetic Developer. It stores the generated identifiers plus authenticated repository URLs in the ignored .gitlab.env file.

#!/bin/sh
set -eu
[ "$#" -eq 2 ] || { printf 'usage: %s SOURCE TARGET\n' "$0" >&2; exit 2; }
[ -f .gitlab.env ] || { printf 'NOT MERGED: .gitlab.env is missing\n' >&2; exit 1; }
. ./.gitlab.env
SOURCE=$1; TARGET=$2; api="http://$GITLAB_HOST/api/v4"; newline='
'
api_request() {
  method=$1; url=$2; shift 2
  if ! response=$(curl --silent --show-error --request "$method" --header "PRIVATE-TOKEN: $ROOT_TOKEN" --write-out '\n%{http_code}' "$@" --url "$url"); then status=000; body='curl transport failure'; return 1; fi
  status=${response##*"$newline"}; body=${response%"$newline"*}
  case "$status" in 2??) return 0 ;; *) return 1 ;; esac
}
if ! api_request POST "$api/projects/$PROJECT_ID/merge_requests" --data-urlencode "source_branch=$SOURCE" --data-urlencode "target_branch=$TARGET" --data-urlencode "title=Merge $SOURCE into $TARGET"; then
  printf 'NOT MERGED: %s into %s, status %s\n%s\n' "$SOURCE" "$TARGET" "$status" "$body" >&2; exit 1
fi
IID=$(printf '%s\n' "$body" | jq -er '.iid'); merge_status=unchecked; attempt=1
while [ "$attempt" -le 30 ]; do
  api_request GET "$api/projects/$PROJECT_ID/merge_requests/$IID?with_merge_status_recheck=true" || { printf 'NOT MERGED: status %s\n' "$status" >&2; exit 1; }
  merge_status=$(printf '%s\n' "$body" | jq -er '.detailed_merge_status')
  [ "$merge_status" = mergeable ] && break
  sleep 2; attempt=$((attempt + 1))
done
[ "$merge_status" = mergeable ] || { printf 'NOT MERGED: %s into %s (!%s), status %s\n' "$SOURCE" "$TARGET" "$IID" "$merge_status" >&2; exit 1; }
api_request PUT "$api/projects/$PROJECT_ID/merge_requests/$IID/merge" || { printf 'NOT MERGED: status %s\n%s\n' "$status" "$body" >&2; exit 1; }
state=$(printf '%s\n' "$body" | jq -er '.state')
[ "$state" = merged ] || { printf 'NOT MERGED: state %s\n' "$state" >&2; exit 1; }
printf 'MERGED: %s into %s (!%s)\n' "$SOURCE" "$TARGET" "$IID"

What does this script do?

The script creates a merge request through the GitLab API. It waits for a mergeable state before completing the merge.

#!/bin/sh
set -eu
git fetch --quiet --tags origin +refs/heads/main:refs/remotes/origin/main +refs/heads/develop:refs/remotes/origin/develop
missing=0
for tag in $(git tag -l 'v*' --merged origin/main); do
  if ! git merge-base --is-ancestor "$tag" origin/develop; then printf 'NOT IN DEVELOP: %s\n' "$tag"; missing=$((missing + 1)); fi
done
if [ "$missing" -eq 0 ]; then printf 'BACK-MERGE OK: every tag on main is in develop\n'; exit 0; fi
printf 'BACK-MERGE MISSING: %s\n' "$missing"
exit 1

What does this script do?

The report finds shipped tags that are missing from develop. A nonzero exit turns missing reconciliation into pipeline evidence.

#!/bin/sh
set -eu
[ -f .gitlab.env ] || { printf 'API ERROR: status 000\n.gitlab.env is missing\n' >&2; exit 1; }
. ./.gitlab.env
api="http://$GITLAB_HOST/api/v4"
newline='
'
api_request() {
  method=$1; url=$2; shift 2
  if ! response=$(curl --silent --show-error --request "$method" --header "PRIVATE-TOKEN: $ROOT_TOKEN" --write-out '\n%{http_code}' "$@" --url "$url"); then printf 'API ERROR: status 000\ncurl transport failure\n' >&2; exit 1; fi
  status=${response##*"$newline"}; body=${response%"$newline"*}
  case "$status" in 2??) return 0 ;; *) printf 'API ERROR: status %s\n%s\n' "$status" "$body" >&2; exit 1 ;; esac
}
api_request DELETE "$api/projects/$PROJECT_ID"
api_request DELETE "$api/users/$DEV_ID?hard_delete=true"
rm -f .gitlab.env
printf 'TEARDOWN OK\n'

What does this script do?

The teardown script deletes the disposable GitLab project plus synthetic user. It also removes the local credential file.

# Quickstart

Use Cursor's WSL Bash terminal. The same Bash form works on Linux and macOS.

1. Pass Day Zero.
2. Seed main, develop, and v1.3.9.
3. Push the four reader files before protection.
4. Protect main, develop, and v*.
5. Walk release 1.4.0 and hotfix 1.4.1.
6. Prove all refusals.
7. Reconcile and ship privately.
8. Delete the local lab.

Rule: Cut the version, then the branch.

What does this page record?

The quickstart preserves the full lab sequence. It keeps the release rule beside the operational steps.

# Runbook

Release: cut `release/X.Y.Z` from develop; require branch, VERSION, and unused tag to agree; test; keep staging manual; merge to main; tag; verify production; merge main to develop.

Hotfix: cut `hotfix/X.Y.Z` from main; fail a regression test; fix; follow the same merge, tag, production, and back-merge path.

Wrong answers: green malformed release, green mismatch, existing tag that passes, production from a branch, accepted Developer write, off-main production, or zero report while a tag is missing.

Teardown: delete project and synthetic user, remove origin, containers, and network. Keep private GitHub and Linear.

What does this page record?

The runbook defines the release plus hotfix routes. It also names the outcomes that invalidate the evidence.

# Decision Index

| Decision | Alternative | Cost | Reversal |
| --- | --- | --- | --- |
| Disposable local GitLab | Simulate controls | Unsupported host, constrained memory | Supported Linux GitLab |
| 1.3.9 seed, 1.4.0 release, 1.4.1 hotfix | Reuse one version | One extra tag | Atomic release orchestration |
| Retain private GitHub and Linear | Keep only disposable GitLab | Two docs PRs | Permanent supported GitLab |

Reader-owned ADR 0001 carries the full decision record.

What does this page record?

The decision index connects each choice to an alternative. It also records its cost plus reversal condition.

# Implementation Plan

Rule: Cut the version, then the branch.

Scope: one disposable delivery lab and one private handoff.

Tests: five checker outcomes; dev and integration; release and hotfix test; manual staging; valid-tag production; newline fail then pass; missing back-merges; protected-ref denials; off-main-tag denial.

Control traceability:
- CM-3 Configuration Change Control
- CM-5 Access Restrictions for Change
- SA-10 Developer Configuration Management
- Protect All Forms of Code from Unauthorized Access and Tampering (PS.1)

This is traceability, not certification. Total cost: 0.00 USD. No cloud infrastructure is provisioned.

What does this page record?

The plan connects the test set to the named controls. It limits the claim to traceability.

# Evidence Set

Mark each row OBSERVED or NOT REHEARSED. Expected text is not observed until its command runs.

| ID | Evidence | Wrong answer |
| --- | --- | --- |
| E1 | Day Zero versions, sign-in, runner | missing tool or silent runner |
| E2 | develop dev and integration | production or missing line |
| E3 | five checker outcomes | green wrong input |
| E4 | release test, staging, production | branch production |
| E5 | v1.4.0 missing then OK | zero while missing |
| E6 | newline fail then pass | pass before fix |
| E7 | hotfix test and production | branch production |
| E8 | `NOT IN DEVELOP: v1.4.1` | zero while missing |
| E9 | branch and tag rejected | accepted write |
| E10 | `FAIL: v9.8.0 is not on main` | production deploy |
| E11 | scheduled report, final OK, Go PASS | non-schedule report job |
| E12 | private repo, two PRs, two Done issues, release | public or incomplete handoff |

What does this page record?

The evidence table defines what each proof must show. Its wrong-answer column prevents a green status from becoming weak evidence.

# Engineering Readout

I own this map. I accept GitFlow's extra merges because E3 controls release identity, E4 and E7 separate branch testing from tag production, E5 and E8 expose missing shipped code, and E9 through E11 prove role, ancestry, and reconciliation controls. I reverse this choice when a simpler flow produces the same evidence.

Evidence: E1 through E12 in `docs/evidence.md`.

What does this page record?

The engineering readout defends each extra merge through evidence. It also states when a simpler flow should replace this model.

# Developers and Change Board Readout

Developers merge feature work to develop; the change board accepts release or hotfix merge requests into main only after version checks, test, and optional manual staging; production moves only when a protected `vX.Y.Z` tag points to main, then main is merged back to develop.

Evidence: E1 through E12. Cost: 0.00 USD.

What does this page record?

The leadership readout compresses the release map into one sentence. It cites the same evidence set used by engineering.

# Lessons, Limits, and Fidelity Note

Lessons: version identity starts before deployment; shipped tags must return to develop; role protection and ancestry checks solve different problems; one evidence set serves both readouts.

Limits:
- The root token is embedded in remote origin and remains in `.git/config` until `git remote remove origin`.
- `.gitlab.env` contains root and synthetic Developer credentials and stays ignored.
- The instance is throwaway, the user synthetic, and no program or personal data is entered.
- Docker socket access gives Runner control of the local Docker daemon. Run no untrusted jobs.

Fidelity:
- The deploy jobs only print.
- The developers' source host is not rehearsed.
- One Developer stands in for a team.
- The schedule is one manual run or cut. A cut run is NOT REHEARSED.
- GitLab CE stands in for the platform's own.
- Local GitLab uses loopback HTTP on an unsupported Windows Docker host.
- Eight GB is constrained; the baseline is 16 GB.
- Alpine minor and CI Git packages are mutable.
- GitHub stays private; no Actions, Codespaces, or paid Linear features are used.
- Unobserved runner output is NOT REHEARSED.
- Service tag v1.4.1 predates final docs PRs.
- No compliance or production-readiness claim is made.

Licences: GitLab CE MIT Expat; Runner MIT; Go BSD-3-Clause; Git GPL-2.0-only; curl curl licence; jq MIT.

What does this page record?

This note separates demonstrated behavior from lab limitations. It prevents the disposable build from implying production readiness.

The four reader-owned files are intentionally absent from this supplied-code reference. You author them from the control contract in the next substep.

Protect the root token

The next command handles a live root token. Replace the plain placeholder directly in your terminal without saving the completed command in a file.

The generated remote URL contains that token. Never print the origin remote.

  • Create the private GitLab project plus synthetic Developer by running these commands:
export GITLAB_HOST=localhost:8929
export ROOT_TOKEN='paste-the-root-token-once'
sh scripts/setup-gitlab.sh
. ./.gitlab.env

What do these commands do?

The first two lines provide the local GitLab host plus root credential to the bootstrap script. The script creates root/branching-model plus the synthetic dev-ana Developer.

The final line loads the generated project identifiers plus authenticated repository URLs into the current shell. The credential file remains ignored.

  • Confirm that the terminal prints a line beginning with SETUP OK.
  • Confirm that the same line names dev-ana as Developer.

Bootstrap failed?

Check that GitLab still loads at http://localhost:8929. Confirm that the token has both required scopes.

If the script reports an existing project or user, the lab has stale state from an earlier attempt. Help me diagnose this GitLab bootstrap failure.

  • Initialize the repository plus seed main plus develop by running these commands:
git init -b main
git config user.name 'Branching Model Lab'
git config user.email 'branching-model@example.test'
git add -A
git commit -m 'service 1.3.9'
git tag v1.3.9
git remote add origin "$ORIGIN"
git push origin main v1.3.9 main:develop
git fetch origin develop
git switch -c develop --track origin/develop

What do these commands do?

The commands create a Git repository with the required lab identity. They commit the cold-start service plus tag it as v1.3.9.

The push seeds both protected-branch candidates from one commit. The final command creates local develop with tracking configured for origin/develop.

  • Confirm that Git reports an initial commit on main.
  • Confirm that the push creates main plus develop plus v1.3.9.
  • Confirm that the final line says the local develop branch tracks origin/develop.

Seed push failed?

Confirm that the bootstrap printed SETUP OK before you initialized the repository. A missing environment load leaves ORIGIN unavailable.

Do not inspect the remote URL because it contains the root token. Help me diagnose the seed push safely.

Author the release control contract

The four reader-owned files turn the GitFlow policy into executable controls. Each file covers a different layer of the same release rule.

What must each file contain?

  • The BRANCHING.md map has columns for Ref plus Created from plus Change route plus Deployment. Its six rows appear in this order: feature/* plus develop plus release/X.Y.Z plus main plus hotfix/X.Y.Z plus vX.Y.Z.
  • The map sends features from develop back to develop through merge requests. It assigns no deployment to feature branches.
  • The map assigns dev plus integration to develop. It routes release plus hotfix work through main before returning shipped code to develop.
  • The map assigns test plus manual staging to release and hotfix branches. It assigns production to tags created from main.
  • The ADR title is Cut the version, then the branch. Its fields are Rule plus Choice plus Alternative plus Cost plus Reversal.
  • The ADR explains why each extra merge assembles work or isolates stabilization or repairs production or returns shipped code to develop.
  • The checker prints exact SKIP plus malformed FAIL plus version mismatch FAIL plus existing-tag FAIL plus PASS outcomes. Only SKIP plus PASS exit with code zero.
  • The pipeline uses alpine:3.24 plus apk add --no-cache git plus GIT_DEPTH: "0".
  • The pipeline has check plus deploy plus report stages. Deployments run only for push pipelines.
  • The pipeline deploys dev plus integration from develop. It tests release plus hotfix branches.
  • The pipeline keeps staging manual. It deploys production only when a valid tag is reachable from main.
  • The pipeline runs back-merge-report only for scheduled pipelines.
  • Write the six-row release map in BRANCHING.md.
  • Write the decision record in docs/adr/0001-branching-model.md.
  • Write the five-outcome checker in scripts/check-release-branch.sh.
  • Write the pipeline in .gitlab-ci.yml.
  • Save all four reader-owned files.

Before you push, which two deployment environments should a develop pipeline print?

  • Commit the release contract to develop by running these commands:
git add BRANCHING.md docs/adr/0001-branching-model.md scripts/check-release-branch.sh .gitlab-ci.yml
git commit -m map
git push

What do these commands do?

The first command stages only the four reader-owned files. The commit keeps the release contract separate from the supplied baseline.

The push starts the first CI/CD pipeline from develop.

  • Switch back to the GitLab project in your browser.
  • Select Build in the left sidebar.
  • Select Pipelines.
  • Select the pipeline for the map commit.
  • Select each deploy job to inspect its log.

You should see DEPLOY dev: develop plus DEPLOY integration: develop. You should not see a production deployment.

That is the first control path working: develop now produces only its approved environments.

Pipeline missing a deployment line?

Check that the pipeline source is a push to develop. Confirm that both deployment jobs target the exact branch name.

If the runner is Offline, mark both runtime lines NOT REHEARSED. Help me diagnose the develop pipeline.

Protect branches and release tags

Protected refs turn the map into an authorization boundary. Maintainers can merge reviewed work while direct branch pushes remain blocked.

Tag protection covers the production trigger. Only Maintainers can create names matching v*.

  • Return to the root/branching-model project in GitLab.
  • Open Branch rules.
  • Protect the main branch.
  • Set Allowed to merge to Maintainers for main.
  • Set Allowed to push and merge to No one for main.
  • Protect develop with the same two permission values.
  • Protect the v* tag pattern.
  • Set Allowed to create to Maintainers for v*.

Before you inspect the completed rules, which permission should prevent every direct write to main plus develop?

  • Review the completed branch rules.
  • Confirm that main shows Maintainers for merges.
  • Confirm that main shows No one for direct pushes.
  • Confirm that develop shows the same two values.
  • Confirm that v* shows Maintainers for creation.

You should now see merge-only access on both long-lived branches. You should also see Maintainer-only creation for version tags.

The lab is now protected without losing its baseline history. Every later change to main plus develop must travel through a merge request.

Permissions do not match?

Check each ref separately because branch permissions do not configure tag permissions. A Developer write permission on either branch invalidates this control.

Confirm that the tag rule uses the wildcard v*. Help me correct these protected-ref rules.

Your seed plus governance files plus protected refs are ready. Next, you will walk release 1.4.0 through every checker outcome.

Walk Release 1.4.0

Your protected refs now enforce the six-row map. The next proof is a complete GitFlow release path for 1.4.0.

A release path is trustworthy when the checker exposes every valid outcome and every refusal. In this step, you will capture those outcomes before closing the release loop.

In this step, get ready to:
  • Exercise the release checker across valid inputs and invalid inputs.
  • Publish release 1.4.0 through the protected merge path.
  • Expose the missing back-merge before reconciling develop.
Exercise the release checker

The checker separates ordinary branches from malformed release branches. It also refuses a release whose branch name disagrees with VERSION.

Before you run these checks, which input do you expect to skip and which inputs do you expect to fail?

  • Exercise the first three checker paths one at a time by running these commands:
CI_COMMIT_REF_NAME=develop sh scripts/check-release-branch.sh
CI_COMMIT_REF_NAME=release/next sh scripts/check-release-branch.sh
CI_COMMIT_REF_NAME=release/1.4.0 sh scripts/check-release-branch.sh

What did these checks prove?

  • The develop check prints SKIP: develop is not a release or hotfix branch. This keeps ordinary branches outside the release rule.
  • The malformed branch prints FAIL: release/next does not match release/X.Y.Z or hotfix/X.Y.Z. This proves the checker requires a versioned branch name.
  • The release branch input prints FAIL: branch says 1.4.0, VERSION says 1.3.9. This proves the branch decision cannot outrun the version file.

Seeing a different checker result?

Confirm that your terminal still uses the develop checkout from the previous step. Confirm that VERSION still contains 1.3.9.

If any invalid input passes, stop the release because the checker does not match the required five-outcome contract.

Help me diagnose an unexpected release checker result.

The mismatch is intentional. Now the version decision can happen before the branch carries releasable identity.

Before you cut the branch, do you expect the checker to pass before or after VERSION changes?

  • Cut the release branch and align its version by running these commands:
git switch -c release/1.4.0
printf '1.4.0\n' > VERSION
CI_COMMIT_REF_NAME=release/1.4.0 sh scripts/check-release-branch.sh

Why does the checker pass now?

  • The new release/1.4.0 branch starts from develop.
  • The version update gives the branch and VERSION the same release identity.
  • The checker prints PASS: release/1.4.0 carries VERSION 1.4.0 and no tag v1.4.0 yet. The future tag is still unused.

Release check still failing?

Confirm that the current branch is release/1.4.0. Confirm that VERSION contains only 1.4.0 with a final newline.

Help me fix the release branch PASS check.

✔️ Awesome, I've got everything!

Great. Your release branch now carries the intended 1.4.0 version.

ⓧ I'd like to double check the full code

1.4.0
Merge and tag the release

The release branch must pass testing before protected main receives it. The production tag must then point to the merged commit on main.

  • Publish the versioned release branch by running these commands:
git add VERSION
git commit -m '1.4.0'
git push -u origin HEAD

What does the release push trigger?

  • The commit records the VERSION change on release/1.4.0.
  • The push starts the release pipeline in GitLab.
  • A rehearsed runner prints DEPLOY test: release/1.4.0.
  • The optional deploy-staging job waits for a manual start.
  • Record these pipeline lines as NOT REHEARSED if the runner was marked offline during Day Zero.

Release pipeline not ready?

Confirm that the pushed branch is release/1.4.0. Check that the release test job completed before continuing.

Leave the optional staging job waiting manually. Production must not run from this branch.

Help me diagnose the release branch pipeline.

A merge request is the allowed path into protected main. Direct pushes remain blocked.

Before you merge, do you expect the protected branch to accept the API-driven merge request?

  • Merge the tested release into protected main by running these commands:
sh scripts/mr.sh release/1.4.0 main
git fetch origin main

What did the merge request establish?

  • The script creates the permitted release merge request through the GitLab API.
  • A successful merge prints MERGED: release/1.4.0 into main (!N). The merge request number replaces N.
  • The fetch updates origin/main to the protected release commit.

Merge request not completing?

Confirm that release CI passed. A failed pipeline can prevent the merge request from becoming mergeable.

Confirm that the ignored .gitlab.env file still exists because scripts/mr.sh loads its API connection values.

Help me diagnose the release merge request.

Before you create the tag, which commit should v1.4.0 identify to satisfy the production ancestry rule?

  • Create the protected production tag from the fetched main commit by running these commands:
git tag v1.4.0 origin/main
git push origin v1.4.0

What does the protected tag prove?

  • The tag points to origin/main after the release merge.
  • The protected v* rule allows the Maintainer-authenticated remote to create the tag.
  • A rehearsed tag pipeline prints DEPLOY production: v1.4.0.
  • Production output from release/1.4.0 would invalidate the evidence.

Production pipeline missing?

Confirm that v1.4.0 points to origin/main. Confirm that you pushed the tag after the release merge completed.

If the runner was marked offline earlier, record the production line as NOT REHEARSED.

Help me diagnose the protected release tag pipeline.

Prove and repair the back-merge gap

The production tag creates two final checks. The checker must refuse version reuse before the report exposes shipped code missing from develop.

Before you rerun the checker, do you expect the now-used release version to pass or fail?

  • Test the existing-tag refusal by running this command:
CI_COMMIT_REF_NAME=release/1.4.0 sh scripts/check-release-branch.sh

Why is this failure required?

The checker prints FAIL: tag v1.4.0 already exists with exit 1. This prevents the release version from being reused after publication.

Existing tag check passed?

Stop if the checker prints a PASS after v1.4.0 exists. Confirm that the tag is available in your local repository.

Help me diagnose a green existing-tag check.

Before you run the report, do you expect develop to contain the new production tag's commit?

  • Reveal the missing release back-merge by running this command:
sh scripts/backmerge-report.sh

What did the missing back-merge prove?

  • The report prints NOT IN DEVELOP: v1.4.0.
  • The summary prints BACK-MERGE MISSING: 1.
  • Exit 1 is the required result because shipped code has not returned to develop.

Missing report did not fail?

A zero exit while v1.4.0 is absent from develop invalidates the report. Confirm that the release back-merge has not already happened.

Help me diagnose the missing back-merge report.

The failing report is the intended control signal. The release can now return to develop through the protected merge path.

Before you run the final report, do you expect any production tag to remain outside develop?

  • Reconcile the release and verify its ancestry by running these commands:
sh scripts/mr.sh main develop
sh scripts/backmerge-report.sh

What closes the release loop?

  • The merge script prints MERGED: main into develop (!N).
  • The final report prints BACK-MERGE OK: every tag on main is in develop.
  • The successful report proves that protected develop now contains the shipped v1.4.0 history.

Final back-merge still missing?

Confirm that scripts/mr.sh printed a successful merge into develop. The report fetches both protected branches before checking ancestry.

Help me diagnose the final back-merge verification.

That closes the release loop. Your shipped v1.4.0 history now returns to protected develop.

The release path is evidenced. Next, you will force a regression to fail before shipping hotfix 1.4.1.

Walk Hotfix 1.4.1

Release v1.4.0 now sits on protected main. Its completed back-merge returned the shipped history to develop.

A GitFlow hotfix starts from the commit that production received. The regression must expose the shipped newline defect before you repair it.

In this step, get ready to:
  • Expose the shipped newline defect with a regression test.
  • Publish the tested repair on hotfix/1.4.1.
  • Release protected tag v1.4.1 while preserving the missing back-merge evidence.
Reproduce the shipped newline defect

The hotfix branch needs the exact code that produced v1.4.0. Creating it from origin/main ties the regression evidence to the shipped commit.

  • Create hotfix/1.4.1 from the latest origin/main state by running these commands:
git fetch origin main
git switch -c hotfix/1.4.1 origin/main

What Do These Commands Establish?

  • The fetch refreshes your local view of the protected main branch.
  • The switch creates hotfix/1.4.1 from the fetched production state.

Your terminal confirms the branch switch. The new branch still contains the shipped 1.4.0 service.

Hotfix Branch Not Created?

Confirm that the repository can reach origin. Check that the earlier release merge exists on main.

Still blocked? Help me diagnose why the hotfix branch cannot be created from origin/main.

The Go test calls versionHandler without starting the HTTP server. It detects whether the response contains surrounding whitespace.

  • Create main_test.go inside the branching-model folder by pasting this code in the Cursor Explorer sidebar:
package main

import (
	"net/http/httptest"
	"strings"
	"testing"
)

func TestVersionHasNoTrailingWhitespace(t *testing.T) {
	response := httptest.NewRecorder()
	request := httptest.NewRequest("GET", "/version", nil)
	versionHandler(response, request)
	got := response.Body.String()
	if got != strings.TrimSpace(got) {
		t.Fatalf("/version returns %q: trailing whitespace", got)
	}
}

What Does This Test Prove?

  • The response recorder captures what versionHandler writes.
  • The test request calls the existing /version path.
  • The comparison detects whitespace around the returned version.
  • The failure preserves the original response as evidence.
  • Save main_test.go.

Before you run the test, predict whether the shipped response preserves or removes the newline.

  • Run the regression test against the unchanged shipped code with this command:
go test ./...

What Does This Test Run Establish?

The command runs every test in the module. A nonzero result proves that the new test detects the shipped behavior.

You will see a failure containing /version returns "1.4.0\n": trailing whitespace. This is the intended regression evidence.

Did the Regression Test Pass?

A pass is the wrong result at this point. Confirm that main.go still writes the embedded version value without trimming it.

Check the test against the target file with this guide: Help me find why the newline regression test passes before the fix.

Publish hotfix 1.4.1

The failed test isolates the defect in versionHandler. The handler can trim the embedded value while keeping VERSION as its source.

  • Replace the contents of main.go with this corrected service code:
package main

import (
	_ "embed"
	"net/http"
	"strings"
)

//go:embed VERSION
var version string

func healthHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }
func versionHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(strings.TrimSpace(version))) }

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/version", versionHandler)
	panic(http.ListenAndServe("127.0.0.1:8080", mux))
}

How Does the Fix Work?

  • The strings import provides the trimming function.
  • The embedded VERSION file remains the version source.
  • The updated handler removes surrounding whitespace before writing the response.
  • Save main.go.

Before you rerun the test, predict whether the same regression now passes.

  • Verify the production-code fix by running this command:
go test ./...

What Does the Second Run Prove?

The same test now checks the trimmed response. A successful exit proves that the production change closes the defect exposed by the first run.

The test now passes. Your repair addresses the shipped failure with a repeatable check.

Still Seeing the Whitespace Failure?

Confirm that main.go imports strings. Check that versionHandler writes strings.TrimSpace(version).

Compare both files with this guide: Help me diagnose why the Go test still reports trailing whitespace.

The tested repair now needs release identity 1.4.1. One commit keeps the fix plus its regression test plus its version change together.

  • Publish the completed hotfix branch by running these commands:
printf '1.4.1\n' > VERSION
git add main.go main_test.go VERSION
git commit -m '1.4.1'
git push -u origin HEAD

What Do These Commands Publish?

  • The first command records 1.4.1 in VERSION.
  • The staging command selects the fix plus its test plus the version file.
  • The commit records the complete hotfix as one reviewable change.
  • The push publishes hotfix/1.4.1 for its branch pipeline.
  • Return to the pipeline page in the GitLab project from earlier.
  • Select the pipeline for hotfix/1.4.1.
  • Inspect the test job log for its deployment evidence.

When the runner is rehearsed, you will see DEPLOY test: hotfix/1.4.1. The optional staging job waits for a manual run.

  • Record the hotfix pipeline line as NOT REHEARSED if the runner remains offline.

Hotfix Pipeline Not Passing?

Confirm that the pushed branch contains 1.4.1 in VERSION. Check that the local test passed before the push.

Use the job log with this guide: Help me diagnose the hotfix branch pipeline failure.

✔️ Awesome, I've got everything!

Your hotfix files now match the tested 1.4.1 state. Make sure all three files are saved.

ⓧ I'd like to double check the full code

package main

import (
	_ "embed"
	"net/http"
	"strings"
)

//go:embed VERSION
var version string

func healthHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }
func versionHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(strings.TrimSpace(version))) }

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/version", versionHandler)
	panic(http.ListenAndServe("127.0.0.1:8080", mux))
}
package main

import (
	"net/http/httptest"
	"strings"
	"testing"
)

func TestVersionHasNoTrailingWhitespace(t *testing.T) {
	response := httptest.NewRecorder()
	request := httptest.NewRequest("GET", "/version", nil)
	versionHandler(response, request)
	got := response.Body.String()
	if got != strings.TrimSpace(got) {
		t.Fatalf("/version returns %q: trailing whitespace", got)
	}
}
1.4.1
Release the protected hotfix

Production accepts a protected version tag whose commit is already on main. The merge request moves the reviewed hotfix onto that protected branch.

  • Merge hotfix/1.4.1 into main by running these commands:
sh scripts/mr.sh hotfix/1.4.1 main
git fetch origin main

What Does This Merge Establish?

The script creates the merge request through the existing lab workflow. The fetch refreshes origin/main after the protected merge completes.

You will see a message containing MERGED: hotfix/1.4.1 into main (!N). The corrected service now sits on the protected production branch.

Hotfix Not Merged?

Confirm that the branch pipeline passed when rehearsed. Check that .gitlab.env still exists for the merge-request script.

Inspect the script result with this guide: Help me diagnose why the hotfix merge request did not merge.

Before you push the tag, predict which ref should produce the production deployment evidence.

  • Create protected tag v1.4.1 from origin/main by running these commands:
git tag v1.4.1 origin/main
git push origin v1.4.1

Why Tag origin/main?

The tag points to the fetched protected-branch commit. Its push triggers the production ancestry control in the reader-owned pipeline.

When the runner is rehearsed, you will see DEPLOY production: v1.4.1. Production output from hotfix/1.4.1 would invalidate the evidence.

  • Record the production pipeline line as NOT REHEARSED if the runner remains offline.

Production Job Missing?

Confirm that v1.4.1 points to origin/main. Check that the tag push reached the protected repository.

Inspect the evidence path with this guide: Help me diagnose why v1.4.1 did not produce the production deployment line.

The hotfix is now shipped on main. No merge has returned that production history to develop.

Before you run the report, predict whether develop contains the new production tag.

  • Check the hotfix reconciliation state by running this command:
sh scripts/backmerge-report.sh

What Does the Report Prove?

  • The report prints NOT IN DEVELOP: v1.4.1.
  • The report prints BACK-MERGE MISSING: 1.
  • The script exits with status 1.

This intended failure proves that the shipped hotfix has not returned to develop. Keep this gap intact for the reconciliation step.

Did the Report Say Everything Is Reconciled?

Stop if the report prints the clean result. Confirm that no one merged main into develop during this step.

Keep every ref unchanged while you inspect the cause: Help me identify why v1.4.1 already appears in develop.

Your hotfix now has failure evidence plus a tested repair plus protected production evidence. Next, you will prove the authorization refusals before reconciling the deliberate gap.

Prove Refusals, Reconcile, and Ship

The hotfix is now on protected main as v1.4.1. That is the risky repair complete: production received the fix through the controlled release path.

The deliberate back-merge gap still leaves develop behind. This final step uses GitLab to prove authorization plus ancestry controls. You then reconcile the branches before retaining the evidence in private GitHub and Linear handoffs.

In this step, get ready to:
  • Prove that the synthetic Developer cannot write to protected branches or protected tags.
  • Reconcile develop after capturing the scheduled back-merge evidence.
  • Publish the private repository, documentation pull requests, Linear records, and GitHub Release.
Prove the three refusal controls

A protected ref must reject unauthorized writes even when the proposed content is valid. A production tag must also point to main before the pipeline can deploy it.

These checks intentionally attempt forbidden writes. Before you run them, which controls do you expect to reject each attempt?

  • Return to the Cursor WSL Bash terminal from the previous step.
  • Attempt the two Developer writes plus the off-main tag push by running:
git push "$DEV" HEAD:develop
git tag v9.9.9
git push "$DEV" v9.9.9
git tag -d v9.9.9
git fetch origin develop main
git tag v9.8.0 origin/develop
git push origin v9.8.0

What do these checks prove?

  • The first push tests whether the synthetic Developer can bypass the protected develop branch.
  • The second push tests whether the same Developer can create a protected release tag.
  • The final push creates v9.8.0 from develop. Its pipeline tests whether an off-main tag can reach production.

You should see You are not allowed to push code to protected branches on this project. for the direct develop push. You should also see You are not allowed to create this tag as it is protected. for v9.9.9.

When the v9.8.0 pipeline finishes, you should see FAIL: v9.8.0 is not on main. Any accepted Developer write or production deployment makes the handoff unpublishable.

Did a forbidden write succeed?

Return to the branch or tag protection settings from Step 2. Confirm that direct pushes to main plus develop are set to No one. Confirm that v* creation is limited to Maintainers.

Do not continue if a Developer write was accepted. The refusal is required evidence.

Help me diagnose an accepted protected-ref write in GitLab.

The failed ancestry check has served its purpose. Remove the temporary tag so only the three valid service tags reach the private handoff.

  • Delete v9.8.0 from GitLab plus your local repository by running:
git push origin :refs/tags/v9.8.0
git tag -d v9.8.0

Why delete the test tag?

  • The remote deletion removes the invalid tag from GitLab.
  • The local deletion prevents the invalid tag from being pushed to GitHub later.

You should see deletion confirmations for the remote plus local copies of v9.8.0. The valid tags remain untouched.

Is the temporary tag still visible?

Check whether the first deletion targeted :refs/tags/v9.8.0. A missing leading colon changes the meaning of the remote ref operation.

Help me remove the temporary v9.8.0 tag without deleting a valid release tag.

Reconcile develop and verify the schedule

The missing v1.4.1 ancestry is still deliberate evidence. Capture the scheduled report before merging main back into develop.

  • Return to the GitLab project page from earlier.
  • Select Build in the project sidebar.
  • Select Pipeline schedules.
  • Create one schedule that targets develop.
  • Run the schedule once using its manual run control.
  • Confirm that only back-merge-report may appear in the scheduled pipeline.
  • Record the schedule result in docs/evidence.md as OBSERVED or NOT REHEARSED.

If the runner is available, the scheduled report should expose the missing v1.4.1 back-merge. If the runner is unavailable, the evidence must say NOT REHEARSED.

Did another scheduled job appear?

Review the schedule rules in the reader-owned .gitlab-ci.yml. Only back-merge-report should accept a scheduled pipeline source.

Help me diagnose why an unexpected GitLab job appeared in the scheduled pipeline.

The schedule preserves the failure before you repair it. Before you reconcile, do you expect the same report to exit zero after main returns to develop?

  • Merge main into protected develop through the existing merge-request script.
  • Verify the repaired ancestry plus the Go regression test by running:
sh scripts/mr.sh main develop
sh scripts/backmerge-report.sh
git fetch origin develop
git switch develop
git merge --ff-only origin/develop
go test ./...

What does reconciliation establish?

  • The merge-request script returns shipped hotfix history to the protected develop branch.
  • The report verifies that every valid tag on main is now contained by develop.
  • The fast-forward updates your local develop branch without creating an extra local merge.
  • The Go test proves that the newline regression remains fixed after reconciliation.

You should see MERGED: main into develop (!N) followed by BACK-MERGE OK: every tag on main is in develop. The Go test should exit successfully.

Is the back-merge still missing?

Confirm that scripts/mr.sh merged main into develop. Then confirm that your local remote-tracking refs were fetched before the report ran.

Help me diagnose why v1.4.1 is still missing from develop after the merge request.

The code history is reconciled. The final local checks now guard the ignored credential file plus the genericization boundary before anything moves to GitHub.

  • Verify the ignored secret file plus the two genericization searches by running:
git check-ignore .gitlab.env
git grep -n -i -E '\b(federal|platform)\b' -- .
git grep -n -i -E '(codename|agency|employer|contract|internal system)' -- .

What do the publication checks protect?

  • The ignore check proves that .gitlab.env stays outside version control.
  • The first search checks for restricted naming patterns in tracked content.
  • The second search checks for operational identity terms that would stop publication.

You should see .gitlab.env from the ignore check. The searches must reveal no real operational name plus no deliverable use of federal.

Any platform result must be a lower-case generic use. Stop publication if you find a proper operational name.

Stop if the searches reveal a real name

Remove the identifying text from the relevant document. Run both searches again before creating the private handoff.

Publish the private handoff

The disposable GitLab lab holds the runtime evidence. The private GitHub repository plus Linear project retain the reviewed documentation after local teardown.

  • Switch to main plus create the private GitHub repository by running:
git switch main
git merge --ff-only origin/main
gh repo create branching-model-delivery --private --source . --remote github --push
git push github develop
git push github v1.3.9 v1.4.0 v1.4.1

What does this publish?

  • The fast-forward aligns local main with the protected GitLab branch.
  • The repository command creates branching-model-delivery as a private GitHub repository.
  • The remaining pushes retain develop plus the three valid release tags.

You should receive the private repository URL. Its refs should include main, develop, v1.3.9, v1.4.0, plus v1.4.1.

Did GitHub reject the publication?

Run the authentication check from Day Zero if GitHub rejects the request. Confirm that the destination repository does not already exist under your account.

Help me diagnose a private GitHub repository creation or ref push failure.

Linear links each documentation change to a handoff issue. The closing syntax in each pull request moves the matching issue to Done after merge.

  • Open Projects in your Linear workspace.
  • Select the + control.
  • Create a project named Branching Model Delivery.
  • Open Settings.
  • Select Features.
  • Select Integrations.
  • Select GitHub.
  • Connect only the private branching-model-delivery repository.

The project now has one private code source. Create the two documentation issues that provide retained review records.

  • Create an issue named Record delivery evidence and readouts.
  • Create an issue named Finalize release documentation.
  • Copy the first issue ID into copied-issue-id.
  • Copy the first issue branch name into copied-git-branch-name.
  • Create the first Linear-linked documentation branch by running:
export LINEAR_ID='[[FIRST_LINEAR_ID="copied-issue-id"]]'
export LINEAR_BRANCH='[[FIRST_LINEAR_BRANCH="copied-git-branch-name"]]'
git switch main
git pull --ff-only github main
git switch -c "$LINEAR_BRANCH"

What does the issue branch establish?

  • The environment variables hold the copied Linear issue identity plus branch name.
  • The pull starts the documentation branch from the latest private GitHub main.
  • The generated branch name lets Linear associate the change with its issue.
  • Open docs/evidence.md in the Cursor Explorer sidebar.
  • Update E1 through E12 with supported OBSERVED or NOT REHEARSED evidence.
  • Open docs/readout-engineering.md in the Cursor Explorer sidebar.
  • Update the engineering readout with the supported E1 through E12 evidence.
  • Save both files.
  • Commit the documentation plus open and merge its pull request by running:
git add docs
git commit -m "$LINEAR_ID documentation evidence"
git push -u github HEAD
gh pr create --base main --head "$LINEAR_BRANCH" --title "$LINEAR_ID documentation evidence" --body "Fixes $LINEAR_ID"
gh pr merge "$LINEAR_BRANCH" --merge --delete-branch
unset LINEAR_ID LINEAR_BRANCH

How does this retain review evidence?

  • The commit records the issue ID beside the documentation evidence.
  • The pull request creates a reviewable handoff record on private GitHub.
  • The merge closes the linked Linear issue through its body text.
  • The final command clears the temporary issue variables from the shell.

You should receive a pull request URL plus a successful merge result. The Record delivery evidence and readouts issue should move to Done.

Did the issue stay open?

Confirm that the pull request body contains the copied issue ID after Fixes. Confirm that the private repository remains connected to the correct Linear workspace.

Help me diagnose why a merged GitHub pull request did not close its Linear issue.

  • Copy the second issue ID into copied-issue-id.
  • Copy the second issue branch name into copied-git-branch-name.
  • Create the second Linear-linked documentation branch by running:
export LINEAR_ID='[[SECOND_LINEAR_ID="copied-issue-id"]]'
export LINEAR_BRANCH='[[SECOND_LINEAR_BRANCH="copied-git-branch-name"]]'
git switch main
git pull --ff-only github main
git switch -c "$LINEAR_BRANCH"

Why start from main again?

  • The pull includes the first merged documentation change before the second branch starts.
  • The new branch isolates final documentation changes in their own retained review record.
  • Open docs/readout-leadership.md in the Cursor Explorer sidebar.
  • Update the leadership readout with the supported E1 through E12 evidence.
  • Open docs/lessons-and-fidelity.md in the Cursor Explorer sidebar.
  • Update the fidelity note with the observed schedule status plus the existing build limits.
  • Save both files.
  • Commit the final documentation plus open and merge its pull request by running:
git add docs
git commit -m "$LINEAR_ID documentation evidence"
git push -u github HEAD
gh pr create --base main --head "$LINEAR_BRANCH" --title "$LINEAR_ID documentation evidence" --body "Fixes $LINEAR_ID"
gh pr merge "$LINEAR_BRANCH" --merge --delete-branch
unset LINEAR_ID LINEAR_BRANCH

What completes the documentation handoff?

  • The second pull request retains the leadership readout plus build-specific fidelity limits.
  • The merged review leaves both Linear issues attached to completed GitHub changes.
  • The private main branch now contains the final documentation state.

You should receive a second pull request URL plus a successful merge result. The Finalize release documentation issue should move to Done.

Did the second pull request fail?

Confirm that local main contains the first merged pull request before recreating the second branch. Check that both edited files were saved before the commit.

Help me diagnose the second Linear-linked documentation pull request.

The release is the final retained checkpoint. Before you create it, do you expect tag verification to accept v1.4.1 after the documentation pull requests?

  • Publish the verified private GitHub Release by running:
git switch main
git pull --ff-only github main
gh release create v1.4.1 --verify-tag --title 'Branching Model Delivery 1.4.1' --notes-from-tag

What does the release preserve?

  • The pull updates local main with both merged documentation reviews.
  • Tag verification confirms that v1.4.1 already exists in the private repository.
  • The release title identifies the retained delivery version.
  • The generated notes use the existing tag history.

You should receive the private release URL. The release page should show Branching Model Delivery 1.4.1 attached to v1.4.1.

Did tag verification fail?

Confirm that v1.4.1 was pushed to the GitHub remote before creating the release. Do not create a replacement tag from the later documentation commit.

Help me diagnose a GitHub Release tag verification failure without moving v1.4.1.

  • Confirm that the GitHub repository remains private.
  • Confirm that main plus develop are present.
  • Confirm that only v1.3.9, v1.4.0, plus v1.4.1 are retained service tags.
  • Confirm that both documentation pull requests are merged.
  • Confirm that both Linear issues show Done.
  • Confirm that the private v1.4.1 Release exists.

Strong finish. You now have refusal evidence, reconciled release ancestry, two retained reviews, and a private handoff built from one evidence set.

Secret mission

Reconstruct the Release from Evidence

Read the existing release history as evidence. Reconstruct the seed, release, hotfix, back-merges, and tag reachability without changing any ref or control.

Clean Up Your Resources

Clean Up Your Resources

Your disposable GitLab Community Edition lab has a total cost of 0.00 USD because it runs locally. Your private handoff in GitHub and Linear remains as retained evidence.

Resources you used:

  • Local GitLab state for the private root/branching-model project. This includes the synthetic user, protected refs, pipelines, and schedule.
  • The generated .gitlab.env credential file.
  • The local Git remote named origin. Its URL stores the root token in .git/config.
  • The Docker containers named gitlab and gitlab-runner.
  • The Docker network named gitlab-net.

Keep everything running

No action needed. Choose this if you are still testing the release controls or collecting evidence.

  • Leave the gitlab and gitlab-runner containers running.
  • Keep the origin remote only while you still need the disposable GitLab lab.
  • Keep .gitlab.env ignored by Git.
  • Use synthetic data only because the lab retains administrator credentials.
  • Retain the private GitHub repository, GitHub Release, and Linear project as handoff evidence.

Keep the lab disposable

The root token remains inside .git/config while origin exists. The Runner also retains access to the local Docker daemon.

Pause - I'll come back to this later

Stop the running containers to free Windows Subsystem for Linux 2 memory. The containers remain available for your return.

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

What does this command do?

This stops the Runner before the GitLab service. Docker keeps both containers so their local state remains available.

You should see gitlab-runner and gitlab printed as Docker stops each container.

Containers did not stop?

  • Return to the Docker environment from Day Zero if the command cannot reach Docker.
  • Check that the container names match gitlab-runner and gitlab exactly.

Help me pause the local GitLab lab safely.

Your private GitHub repository, GitHub Release, and Linear project remain available while the local lab is paused.

Delete - I don't want to use this again

Deleting the local lab permanently removes its project, synthetic user, credentials, containers, and network. Your private GitHub and Linear handoff remains intact.

The supplied teardown script uses the saved GitLab credentials to remove server-side lab state. It also deletes the local .gitlab.env file.

  • Delete the GitLab project and synthetic user by running this command:
sh scripts/teardown-gitlab.sh

What does the teardown script remove?

  • It deletes the private GitLab project, including its branches, tags, pipelines, and schedule.
  • It deletes the synthetic Developer user.
  • It removes .gitlab.env from the local repository.
  • Confirm the API cleanup succeeded by finding TEARDOWN OK in the terminal.

Teardown script did not finish?

  • Keep the remaining local runtime in place until the API cleanup succeeds.
  • Check that .gitlab.env still exists if the script reports that it is missing.

Help me diagnose the teardown script.

The origin remote still stores the root token in .git/config after the API cleanup. Removing that remote clears the stored credential URL.

  • Remove the token-bearing remote by running this command:
git remote remove origin

Why remove the remote?

The origin URL contains the root token in .git/config. Removing the remote clears that stored URL without deleting local commits.

A successful removal returns you to the terminal prompt without an error.

Remote removal failed?

  • Return to the local repository that contains .git/config.
  • Check that the remote name is spelled origin.

Help me remove the stored GitLab remote safely.

The project and credentials are now removed. The remaining Docker artifacts are the two containers and their private network.

  • Remove the containers and network by running these commands:
docker rm -f gitlab-runner gitlab
docker network rm gitlab-net

What do these commands remove?

  • The first command force-removes the gitlab-runner and gitlab containers.
  • The second command removes the private gitlab-net network.

You should see both container names followed by gitlab-net in the terminal output.

Docker cleanup did not finish?

  • Return to the Docker environment from Day Zero if Docker is unavailable.
  • Remove both named containers before retrying the network removal if Docker reports that the network is still in use.

Help me finish the Docker cleanup.

You closed the sensitive part cleanly. The disposable credentials and local services are gone.

Your private GitHub repository, GitHub Release v1.4.1, and Linear records remain ready for review.

Nice Work!

Nice Work!

You completed a GitFlow release governance lab that carried version 1.4.0 through a controlled release. Your private handoff now proves the complete path from protected refs to reconciled shipped code.

What you learned:

  • Defined release governance with a six-row GitFlow map. Enforced that map with a five-outcome release checker.
  • Proved protected refs by rejecting unauthorized Developer writes in GitLab. Proved tag ancestry by denying production from v9.8.0. Confirmed back-merge reconciliation through the scheduled report.
  • Released 1.4.0 through the release path. Shipped 1.4.1 through a regression-tested hotfix. Published a private GitHub Release for v1.4.1. Linked the review trail to two completed Linear issues.
  • Secret Mission: Reconstructed the release graph from the Git evidence without changing refs or controls. Defended every extra merge through the evidence it preserves.

Ready to quiz yourself?