Skip to content

Commit 0b8fa6e

Browse files
simplified branching strategy to protect dev from migration scripts
1 parent 19cb774 commit 0b8fa6e

4 files changed

Lines changed: 503 additions & 172 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# =============================================================================
2+
# Merge Request Guard Rails — Branch Protection via CI
3+
# =============================================================================
4+
# Enforces merge direction policy and migration file protection in MR
5+
# pipelines. These guards run automatically when a merge request is
6+
# created or updated; the MR cannot be merged until they pass.
7+
#
8+
# Include in any consumer pipeline:
9+
# include:
10+
# - project: 'root/templatized-with-parser'
11+
# ref: 'main'
12+
# file:
13+
# - '/.gitlab/ci/merge-guard.yml'
14+
#
15+
# Then instantiate the guard jobs in your .gitlab-ci.yml:
16+
#
17+
# guard:merge-direction:
18+
# extends: .merge_direction_guard
19+
# variables:
20+
# PROMOTION_ORDER: "dev,integration,qa,prod"
21+
#
22+
# guard:migration-files:
23+
# extends: .migration_guard
24+
# variables:
25+
# MIGRATION_PROTECTED_BRANCHES: "dev,integration"
26+
#
27+
# Prerequisites:
28+
# 1. Add a 'guard' stage before your other stages:
29+
# stages:
30+
# - guard
31+
# - generate
32+
# - ...
33+
#
34+
# 2. workflow:rules must include merge_request_event:
35+
# workflow:
36+
# rules:
37+
# - if: $CI_PIPELINE_SOURCE == "merge_request_event"
38+
# - if: $CI_COMMIT_BRANCH == "dev"
39+
# ...
40+
#
41+
# 3. GitLab project settings (manual, one-time):
42+
# - Settings → Merge requests → "Pipelines must succeed" = enabled
43+
# - Settings → Repository → Protected branches:
44+
# Allowed to push: "No one" (forces all changes through MRs)
45+
# Allow force push: disabled
46+
# Allow deletion: disabled
47+
# =============================================================================
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# Guard: One-way merge direction
52+
# ---------------------------------------------------------------------------
53+
# Defines a promotion order (e.g. dev → integration → qa → prod) and fails
54+
# the MR pipeline if the source branch is at the same level or downstream
55+
# of the target branch.
56+
#
57+
# Feature branches (not in the promotion order) can merge into any target.
58+
#
59+
# Entries ending with * are treated as prefix matches:
60+
# PROMOTION_ORDER: "dev,integration,qa*,prod"
61+
# → matches qa, qa-london, qa-staging, etc. at rank 2
62+
#
63+
# Override PROMOTION_ORDER to match your branching model:
64+
# "dev,integration,qa,prod" — four-branch
65+
# "dev,qa,prod" — three-branch
66+
# "dev,main" — two-branch
67+
# ---------------------------------------------------------------------------
68+
.merge_direction_guard:
69+
image: alpine:latest
70+
stage: guard
71+
variables:
72+
PROMOTION_ORDER: "dev,integration,qa,prod"
73+
script:
74+
- |
75+
SOURCE="$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
76+
TARGET="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
77+
78+
echo "Merge request: $SOURCE → $TARGET"
79+
echo "Promotion order: $PROMOTION_ORDER"
80+
echo ""
81+
82+
# ── helper: find the rank of a branch in the promotion order ──
83+
# Returns the 0-based index, or -1 if not found.
84+
# Entries ending with * match as prefixes.
85+
branch_rank() {
86+
local branch="$1" i=0
87+
OLD_IFS="$IFS"; IFS=','
88+
for pattern in $PROMOTION_ORDER; do
89+
case "$pattern" in
90+
*\*)
91+
prefix="${pattern%\*}"
92+
case "$branch" in "$prefix"*) IFS="$OLD_IFS"; echo $i; return ;; esac
93+
;;
94+
*)
95+
if [ "$branch" = "$pattern" ]; then IFS="$OLD_IFS"; echo $i; return; fi
96+
;;
97+
esac
98+
i=$((i + 1))
99+
done
100+
IFS="$OLD_IFS"
101+
echo -1
102+
}
103+
104+
SOURCE_RANK=$(branch_rank "$SOURCE")
105+
TARGET_RANK=$(branch_rank "$TARGET")
106+
107+
echo "Source '$SOURCE' → rank $SOURCE_RANK"
108+
echo "Target '$TARGET' → rank $TARGET_RANK"
109+
echo ""
110+
111+
# If source is not an environment branch (e.g. feature/xyz), allow it
112+
if [ "$SOURCE_RANK" -eq -1 ]; then
113+
echo "Source '$SOURCE' is not in the promotion order — allowed."
114+
exit 0
115+
fi
116+
117+
# If target is not an environment branch, allow it
118+
if [ "$TARGET_RANK" -eq -1 ]; then
119+
echo "Target '$TARGET' is not in the promotion order — allowed."
120+
exit 0
121+
fi
122+
123+
# Both are environment branches — enforce forward-only
124+
if [ "$SOURCE_RANK" -ge "$TARGET_RANK" ]; then
125+
echo "========================================="
126+
echo " BLOCKED — reverse merge detected"
127+
echo "========================================="
128+
echo ""
129+
echo " $SOURCE (rank $SOURCE_RANK) → $TARGET (rank $TARGET_RANK)"
130+
echo ""
131+
echo " Promotions must flow forward:"
132+
echo " $PROMOTION_ORDER"
133+
echo ""
134+
echo " Reverse or same-level merges are not allowed."
135+
echo "========================================="
136+
exit 1
137+
fi
138+
139+
echo "Forward merge ($SOURCE → $TARGET) — OK."
140+
rules:
141+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
142+
allow_failure: false
143+
144+
145+
# ---------------------------------------------------------------------------
146+
# Guard: Migration file protection
147+
# ---------------------------------------------------------------------------
148+
# Fails the MR pipeline if the merge request introduces any changes under
149+
# migrations/** when targeting a protected branch.
150+
#
151+
# Protected branches are those where migration scripts should not be
152+
# manually introduced — they are either auto-generated by CI (integration)
153+
# or don't belong at all (dev).
154+
#
155+
# Entries ending with * are treated as prefix matches (same as above).
156+
#
157+
# Override MIGRATION_PROTECTED_BRANCHES to match your model:
158+
# "dev,integration" — four-branch (migrations generated on integration)
159+
# "dev" — three/two-branch (migrations generated on dev by CI)
160+
# ---------------------------------------------------------------------------
161+
.migration_guard:
162+
image: alpine:latest
163+
stage: guard
164+
variables:
165+
MIGRATION_PROTECTED_BRANCHES: "dev,integration"
166+
MIGRATIONS_PATH: "migrations"
167+
before_script:
168+
- apk add --no-cache git >/dev/null 2>&1
169+
script:
170+
- |
171+
TARGET="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
172+
173+
# ── helper: check if a branch matches any protected pattern ──
174+
is_protected() {
175+
local branch="$1"
176+
OLD_IFS="$IFS"; IFS=','
177+
for pattern in $MIGRATION_PROTECTED_BRANCHES; do
178+
case "$pattern" in
179+
*\*)
180+
prefix="${pattern%\*}"
181+
case "$branch" in "$prefix"*) IFS="$OLD_IFS"; return 0 ;; esac
182+
;;
183+
*)
184+
if [ "$branch" = "$pattern" ]; then IFS="$OLD_IFS"; return 0; fi
185+
;;
186+
esac
187+
done
188+
IFS="$OLD_IFS"
189+
return 1
190+
}
191+
192+
if ! is_protected "$TARGET"; then
193+
echo "Target '$TARGET' is not migration-protected — skipping."
194+
exit 0
195+
fi
196+
197+
echo "Checking for ${MIGRATIONS_PATH}/ changes in MR targeting '$TARGET'..."
198+
199+
# We must check only what the SOURCE branch introduces, not what
200+
# the target branch already has. In MR pipelines, HEAD is the
201+
# merge ref (source merged into target), so diffing base..HEAD
202+
# would include migrations that already exist on the target.
203+
#
204+
# Instead, fetch the source branch and diff the merge base against
205+
# the source branch tip. This shows only the files the source
206+
# branch is contributing.
207+
SOURCE="$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
208+
209+
echo "Fetching source branch '$SOURCE'..."
210+
git fetch origin "$SOURCE" --depth=50 >/dev/null 2>&1
211+
SOURCE_SHA=$(git rev-parse "origin/$SOURCE")
212+
213+
DIFF_BASE="${CI_MERGE_REQUEST_DIFF_BASE_SHA}"
214+
if [ -z "$DIFF_BASE" ] || ! git cat-file -e "$DIFF_BASE" 2>/dev/null; then
215+
echo "Diff-base SHA unavailable; fetching target branch for comparison."
216+
git fetch origin "$TARGET" --depth=50 >/dev/null 2>&1
217+
DIFF_BASE=$(git merge-base "origin/$TARGET" "origin/$SOURCE" 2>/dev/null || echo "origin/$TARGET")
218+
fi
219+
220+
echo "Source branch: $SOURCE ($SOURCE_SHA)"
221+
echo "Diff base: $DIFF_BASE"
222+
echo "Diff: $DIFF_BASE..$SOURCE_SHA -- ${MIGRATIONS_PATH}/"
223+
# Only flag additions, copies, modifications, or renames (ACMR).
224+
# Deletions are allowed — removing migration files from a protected
225+
# branch is fine (and expected when cleaning up dev).
226+
CHANGED=$(git diff --name-only --diff-filter=ACMR "$DIFF_BASE" "$SOURCE_SHA" -- "${MIGRATIONS_PATH}/" 2>/dev/null || true)
227+
228+
if [ -n "$CHANGED" ]; then
229+
echo ""
230+
echo "========================================="
231+
echo " BLOCKED — migration changes detected"
232+
echo "========================================="
233+
echo ""
234+
echo " The following files under ${MIGRATIONS_PATH}/ were changed:"
235+
echo ""
236+
echo "$CHANGED" | sed 's/^/ /'
237+
echo ""
238+
echo " Migration scripts must not be added or modified"
239+
echo " via merge requests into '$TARGET'."
240+
echo ""
241+
echo " On the integration branch, migrations are"
242+
echo " auto-generated by the CI pipeline (flyway diff)."
243+
echo "========================================="
244+
exit 1
245+
fi
246+
247+
echo "No migration changes detected — OK."
248+
rules:
249+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
250+
allow_failure: false

0 commit comments

Comments
 (0)