diff --git a/Makefile b/Makefile
index c4c9f05..020d753 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,7 @@ ACTIONS := \
docker \
node \
git/create_tag \
+ git/promotion-gate \
helm/diff \
helm/template \
helm/upgrade \
diff --git a/README.md b/README.md
index b3f8b1e..b4aa160 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ Reusable composite actions for Gitea CI/CD pipelines.
| Action | Description |
|---|---|
| [git/create_tag](git/create_tag/README.md) | Create and push a git tag in the current repository |
+| [git/promotion-gate](git/promotion-gate/README.md) | Enforce which source branches may merge into which target branches |
### Helm
diff --git a/git/promotion-gate/README.md b/git/promotion-gate/README.md
new file mode 100644
index 0000000..be86dba
--- /dev/null
+++ b/git/promotion-gate/README.md
@@ -0,0 +1,21 @@
+# Promotion Gate
+
+
+## Description
+
+Enforce which source branches may merge into which target branches
+
+
+
+## Inputs
+
+| name | description | required | default |
+| --- | --- | --- | --- |
+| `rules` |
One rule per line: "target:allowed1,allowed2,…". Targets not listed are unrestricted. Allowed entries may use a trailing glob, e.g. hotfix-*. Leave empty (default) to skip entirely.
| `false` | `""` |
+
+
+
+## Runs
+
+This action is a `composite` action.
+
diff --git a/git/promotion-gate/action.yml b/git/promotion-gate/action.yml
new file mode 100644
index 0000000..284e760
--- /dev/null
+++ b/git/promotion-gate/action.yml
@@ -0,0 +1,75 @@
+name: Promotion Gate
+description: Enforce which source branches may merge into which target branches
+inputs:
+ rules:
+ description: |
+ One rule per line: "target:allowed1,allowed2,...". Targets not
+ listed are unrestricted. Allowed entries may use a trailing glob,
+ e.g. `hotfix-*`. Leave empty (default) to skip entirely.
+ default: ""
+
+runs:
+ using: composite
+ steps:
+ - name: Check merge source is allowed for target branch
+ shell: sh
+ env:
+ BASE: ${{ gitea.event.pull_request.base.ref }}
+ HEAD: ${{ gitea.event.pull_request.head.ref }}
+ RULES: ${{ inputs.rules }}
+ run: |
+ if [ -z "$RULES" ]; then
+ echo "No promotion rules configured — skipping."
+ exit 0
+ fi
+
+ rc=0
+ found=0
+
+ oldIFS=$IFS
+ IFS='
+ '
+ set -f
+ set -- $RULES
+ set +f
+ IFS=$oldIFS
+
+ for line in "$@"; do
+ if [ -z "$line" ]; then
+ continue
+ fi
+ target=${line%%:*}
+ allowed=${line#*:}
+ if [ "$target" = "$line" ]; then
+ continue
+ fi
+ if [ "$BASE" = "$target" ]; then
+ found=1
+ matched=0
+ innerIFS=$IFS
+ IFS=','
+ for pat in $allowed; do
+ IFS=$innerIFS
+ case "$HEAD" in
+ $pat)
+ matched=1
+ ;;
+ esac
+ IFS=','
+ done
+ IFS=$innerIFS
+
+ if [ "$matched" -eq 1 ]; then
+ echo "Allowed: '$HEAD' -> '$BASE' (matches: $allowed)"
+ else
+ echo "::error::$BASE only accepts merges from: $allowed — got '$HEAD'"
+ rc=1
+ fi
+ fi
+ done
+
+ if [ "$found" -eq 0 ]; then
+ echo "No promotion-source restriction configured for base '$BASE'"
+ fi
+
+ exit $rc