diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml
new file mode 100644
index 0000000000..5802aeef7b
--- /dev/null
+++ b/.github/workflows/create-release.yaml
@@ -0,0 +1,269 @@
+name: Create Release
+
+on:
+  workflow_dispatch:
+    inputs:
+      version_bump:
+        description: "Version bump type (hotfix branches force 'patch')"
+        required: true
+        default: "patch"
+        type: choice
+        options:
+          - patch
+          - minor
+          - major
+      confirm_major:
+        description: "Confirm major version bump (required when 'major' is selected)"
+        required: false
+        type: boolean
+        default: false
+      pre_release:
+        description: "Create as pre-release"
+        required: false
+        type: boolean
+        default: false
+      dry_run:
+        description: "Dry run - only compute and display the next version without creating a release"
+        required: false
+        type: boolean
+        default: false
+
+concurrency:
+  group: release-creation
+  cancel-in-progress: false
+
+run-name: "Create Release (${{ github.event.inputs.version_bump }}${{ github.event.inputs.dry_run == 'true' && ' - dry run' || '' }})"
+
+jobs:
+  create-release:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    defaults:
+      run:
+        shell: bash -euo pipefail {0}
+
+    steps:
+      # A GitHub App token is required (not the default GITHUB_TOKEN) because releases
+      # created with GITHUB_TOKEN do not trigger downstream `release: created` workflows
+      # due to GitHub's anti-recursion policy. Same pattern used in unstract-cloud.
+      - name: Generate GitHub App Token
+        id: generate-token
+        uses: actions/create-github-app-token@v2
+        with:
+          app-id: ${{ vars.PUSH_TO_MAIN_APP_ID }}
+          private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }}
+          owner: Zipstack
+          repositories: |
+            unstract
+
+      - name: Validate branch and determine release mode
+        id: branch-mode
+        env:
+          BRANCH: ${{ github.ref_name }}
+        run: |
+          if [[ "$BRANCH" == "main" ]]; then
+            MODE=main
+            HOTFIX_LINE=""
+          elif [[ "$BRANCH" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)-hotfix$ ]]; then
+            MODE=hotfix
+            HOTFIX_LINE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
+            echo "Hotfix branch detected. Line: v${HOTFIX_LINE}.x"
+          else
+            echo "::error::Releases must be cut from 'main' or a 'vX.Y.Z-hotfix' branch. Got: '$BRANCH'"
+            exit 1
+          fi
+
+          echo "mode=$MODE" >> "$GITHUB_OUTPUT"
+          echo "hotfix_line=$HOTFIX_LINE" >> "$GITHUB_OUTPUT"
+
+      - name: Validate inputs for hotfix mode
+        if: steps.branch-mode.outputs.mode == 'hotfix'
+        env:
+          BUMP: ${{ github.event.inputs.version_bump }}
+        run: |
+          if [[ "$BUMP" != "patch" ]]; then
+            echo "::error::Hotfix branches only support 'patch' bumps. Got: '$BUMP'"
+            exit 1
+          fi
+
+      - name: Validate major bump confirmation
+        if: github.event.inputs.version_bump == 'major' && github.event.inputs.confirm_major != 'true'
+        run: |
+          echo "::error::Major version bump requires the 'Confirm major version bump' checkbox to be checked."
+          exit 1
+
+      - name: Fetch base release version
+        id: get-latest
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          MODE: ${{ steps.branch-mode.outputs.mode }}
+          HOTFIX_LINE: ${{ steps.branch-mode.outputs.hotfix_line }}
+        run: |
+          if [[ "$MODE" == "main" ]]; then
+            # Use the "latest" API (releases/latest) which respects the explicit
+            # "Set as latest release" flag. Hotfix releases are created with
+            # --latest=false, so this never returns a hotfix tag.
+            LATEST_TAG=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name // empty')
+          else
+            # For hotfixes, find the latest tag on this specific v<major>.<minor>.x line
+            LATEST_TAG=$(gh release list --repo "${{ github.repository }}" \
+              --limit 100 --exclude-drafts --exclude-pre-releases \
+              --json tagName \
+              --jq "[.[] | select(.tagName | startswith(\"v${HOTFIX_LINE}.\"))] | .[0].tagName // empty")
+          fi
+
+          if [[ -z "$LATEST_TAG" ]]; then
+            echo "::error::Could not find a base release to bump from (mode=$MODE)."
+            exit 1
+          fi
+
+          if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            echo "::error::Base release tag '$LATEST_TAG' does not match expected format 'vX.Y.Z'"
+            exit 1
+          fi
+
+          echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
+          echo "Base release: $LATEST_TAG"
+
+      - name: Compute next version
+        id: compute-version
+        env:
+          LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
+          BUMP_TYPE: ${{ github.event.inputs.version_bump }}
+        run: |
+          VERSION="${LATEST_TAG#v}"
+          MAJOR=$(echo "$VERSION" | cut -d. -f1)
+          MINOR=$(echo "$VERSION" | cut -d. -f2)
+          PATCH=$(echo "$VERSION" | cut -d. -f3)
+
+          case "$BUMP_TYPE" in
+            patch)
+              PATCH=$((PATCH + 1))
+              ;;
+            minor)
+              MINOR=$((MINOR + 1))
+              PATCH=0
+              ;;
+            major)
+              MAJOR=$((MAJOR + 1))
+              MINOR=0
+              PATCH=0
+              ;;
+            *)
+              echo "::error::Unknown bump type: '$BUMP_TYPE'"
+              exit 1
+              ;;
+          esac
+
+          NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
+
+          # Strict monotonic increase: equal is NOT allowed, sort -V -C alone
+          # accepts equal values (it checks non-decreasing order).
+          CURRENT_NUM="${LATEST_TAG#v}"
+          NEW_NUM="${NEW_TAG#v}"
+          if [[ "$CURRENT_NUM" == "$NEW_NUM" ]]; then
+            echo "::error::Computed version $NEW_TAG equals current version $LATEST_TAG"
+            exit 1
+          fi
+          if ! printf '%s\n%s\n' "$CURRENT_NUM" "$NEW_NUM" | sort -V -C; then
+            echo "::error::Computed version $NEW_TAG is not greater than current version $LATEST_TAG"
+            exit 1
+          fi
+
+          echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT"
+          echo "Current: $LATEST_TAG  ->  New: $NEW_TAG"
+
+      - name: Check for tag collision
+        id: collision-check
+        env:
+          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
+          NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
+        run: |
+          # Guard against collisions with existing pre-releases or drafts
+          # (which are hidden by --exclude-pre-releases in the earlier lookup).
+          if gh release view "$NEW_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
+            echo "::error::A release with tag '$NEW_TAG' already exists (possibly as a pre-release or draft). Delete it first or pick a different bump type."
+            exit 1
+          fi
+
+      - name: Dry run summary
+        if: github.event.inputs.dry_run == 'true'
+        env:
+          LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
+          NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
+          BUMP: ${{ github.event.inputs.version_bump }}
+          PRERELEASE: ${{ github.event.inputs.pre_release }}
+          MODE: ${{ steps.branch-mode.outputs.mode }}
+        run: |
+          {
+            echo "## Dry Run Summary"
+            echo ""
+            echo "| Field | Value |"
+            echo "|-------|-------|"
+            echo "| Mode | $MODE |"
+            echo "| Current version | $LATEST_TAG |"
+            echo "| Bump type | $BUMP |"
+            echo "| Next version | $NEW_TAG |"
+            echo "| Pre-release | $PRERELEASE |"
+            echo ""
+            echo "No release was created (dry run mode)."
+          } >> "$GITHUB_STEP_SUMMARY"
+
+      - name: Create GitHub release
+        id: create-release
+        if: github.event.inputs.dry_run != 'true'
+        env:
+          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
+          NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
+          LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
+          MODE: ${{ steps.branch-mode.outputs.mode }}
+          PRERELEASE: ${{ github.event.inputs.pre_release }}
+          TARGET_BRANCH: ${{ github.ref_name }}
+        run: |
+          RELEASE_ARGS=(
+            "$NEW_TAG"
+            --title "$NEW_TAG"
+            --target "$TARGET_BRANCH"
+            --generate-notes
+            --notes-start-tag "$LATEST_TAG"
+          )
+
+          # Only mark as "latest" on main releases; hotfixes never override latest
+          # (matches the convention documented in the Hotfix Deployment Guide).
+          if [[ "$MODE" == "main" ]]; then
+            RELEASE_ARGS+=(--latest=true)
+          else
+            RELEASE_ARGS+=(--latest=false)
+          fi
+
+          if [[ "$PRERELEASE" == "true" ]]; then
+            RELEASE_ARGS+=(--prerelease)
+          fi
+
+          gh release create --repo "${{ github.repository }}" "${RELEASE_ARGS[@]}"
+          echo "Release $NEW_TAG created successfully."
+
+      - name: Summary
+        if: success() && github.event.inputs.dry_run != 'true'
+        env:
+          LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
+          NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
+          BUMP: ${{ github.event.inputs.version_bump }}
+          PRERELEASE: ${{ github.event.inputs.pre_release }}
+          MODE: ${{ steps.branch-mode.outputs.mode }}
+        run: |
+          {
+            echo "## Release Created"
+            echo ""
+            echo "| Field | Value |"
+            echo "|-------|-------|"
+            echo "| Mode | $MODE |"
+            echo "| Previous version | $LATEST_TAG |"
+            echo "| New version | $NEW_TAG |"
+            echo "| Bump type | $BUMP |"
+            echo "| Pre-release | $PRERELEASE |"
+            echo "| Triggered by | ${{ github.actor }} |"
+            echo ""
+            echo "The [production build](${{ github.server_url }}/${{ github.repository }}/actions/workflows/production-build.yaml) workflow will be triggered automatically."
+          } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/production-build.yaml b/.github/workflows/production-build.yaml
index bcf4faa3e3..1b34e106c4 100644
--- a/.github/workflows/production-build.yaml
+++ b/.github/workflows/production-build.yaml
@@ -19,7 +19,80 @@ on:
 run-name: "[${{ github.event.release.tag_name || github.event.inputs.tag }}] Docker Image Build and Push (Production)"
 
 jobs:
+  # Validates tag format and version monotonicity within the same major.minor line.
+  # This guards the workflow_dispatch tag input and any release event.
+  # The primary defense against bad tags should be a GitHub tag protection ruleset
+  # restricting v* tag creation to the release bot; this job is the in-workflow
+  # safety net that runs regardless.
+  validate-tag:
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        shell: bash -euo pipefail {0}
+    steps:
+      - name: Determine tag
+        id: get-tag
+        env:
+          RELEASE_TAG: ${{ github.event.release.tag_name }}
+          INPUT_TAG: ${{ github.event.inputs.tag }}
+        run: |
+          TAG="${RELEASE_TAG:-$INPUT_TAG}"
+          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+
+      - name: Validate tag format
+        env:
+          TAG: ${{ steps.get-tag.outputs.tag }}
+        run: |
+          if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            echo "::error::Tag '$TAG' does not match expected format 'vX.Y.Z'."
+            exit 1
+          fi
+          echo "Tag format valid: $TAG"
+
+      - name: Validate version increment (same-line)
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          NEW_TAG: ${{ steps.get-tag.outputs.tag }}
+        run: |
+          # Find the previous release on the same major.minor line. This handles
+          # both normal releases (next patch/minor on main) and hotfixes on older
+          # lines, which are NOT marked as "latest" and would otherwise compare
+          # incorrectly against the unrelated main-line latest.
+          NEW_VER="${NEW_TAG#v}"
+          NEW_MAJOR_MINOR=$(echo "$NEW_VER" | cut -d. -f1-2)
+
+          PREV_TAG=$(gh release list --repo "${{ github.repository }}" \
+            --limit 100 --exclude-drafts --exclude-pre-releases \
+            --json tagName \
+            --jq "[.[] | select(.tagName != \"$NEW_TAG\" and (.tagName | startswith(\"v${NEW_MAJOR_MINOR}.\")))] | .[0].tagName // empty")
+
+          if [[ -z "$PREV_TAG" ]]; then
+            echo "No prior release in line v${NEW_MAJOR_MINOR}.x (new minor/major bump). Skipping increment check."
+            exit 0
+          fi
+
+          if [[ ! "$PREV_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            echo "::error::Previous tag '$PREV_TAG' has unexpected format. Refusing to compare."
+            exit 1
+          fi
+
+          echo "Previous release on line: $PREV_TAG"
+          echo "New release:              $NEW_TAG"
+
+          PREV_VER="${PREV_TAG#v}"
+          if [[ "$PREV_VER" == "$NEW_VER" ]]; then
+            echo "::error::New version $NEW_TAG equals previous version $PREV_TAG on the same line."
+            exit 1
+          fi
+          if ! printf '%s\n%s\n' "$PREV_VER" "$NEW_VER" | sort -V -C; then
+            echo "::error::New version $NEW_TAG is not greater than previous version $PREV_TAG on line v${NEW_MAJOR_MINOR}.x."
+            exit 1
+          fi
+
+          echo "Version increment valid: $PREV_TAG -> $NEW_TAG"
+
   build-and-push:
+    needs: validate-tag
     runs-on: ubuntu-latest
     strategy:
       matrix:
@@ -64,10 +137,14 @@ jobs:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
-      # Set version tag based on event type
+      # Set version tag based on event type. Uses env: to avoid interpolating
+      # user input directly into the run block.
       - name: Set version tag
         id: set-tag
-        run: echo "DOCKER_VERSION_TAG=${{ github.event.release.tag_name || github.event.inputs.tag }}" >> $GITHUB_ENV
+        env:
+          RELEASE_TAG: ${{ github.event.release.tag_name }}
+          INPUT_TAG: ${{ github.event.inputs.tag }}
+        run: echo "DOCKER_VERSION_TAG=${RELEASE_TAG:-$INPUT_TAG}" >> "$GITHUB_ENV"
 
       # Set up additional tags for release builds
       - name: Set image tags
@@ -129,11 +206,12 @@ jobs:
           path: build-status/${{ matrix.service_name }}.json
           retention-days: 1
 
-  # Summary job that runs after all builds
+  # Summary job that runs after all builds. Depends on validate-tag so it doesn't
+  # produce misleading output when validation blocked the build.
   build-summary:
-    needs: build-and-push
+    needs: [validate-tag, build-and-push]
     runs-on: ubuntu-latest
-    if: always()
+    if: always() && needs.validate-tag.result == 'success'
     steps:
       # Download all build status artifacts
       - name: Download build statuses
