|
| 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