commit 7f3a76c98480d90565d964d87ccd26b997cc97cc
Author: amorozov <amorozov@tswf.io>
Date:   Tue Mar 4 15:35:36 2025 +0300

    initial commit

diff --git a/Example.Jenkinsfile b/Example.Jenkinsfile
new file mode 100644
index 0000000..d709d16
--- /dev/null
+++ b/Example.Jenkinsfile
@@ -0,0 +1,115 @@
+def runGroovy(scriptName) {
+    sh "groovy -cp ./.ci/ ./.ci/script/${scriptName}.groovy"
+}
+
+pipeline {
+    agent {
+        docker {
+            image 'git.tswf.io/docker-base-images/jdk14-alpine:0.1.4'
+            // Монтируем сокет для DooD. Так как сейчас все наши билд агенты запущены в SysBox, то это достаточно безопасно для хост системы. У Агента свой докер демон, его не жалко.
+            args '-v /var/run/docker.sock:/var/run/docker.sock'
+        }
+    }
+    stages {
+        // Настраиваем глобальные переменные окружения для сборки
+        stage('Prepare: Base envs') {
+            steps {
+                script {
+                    env.CI_PROPERTIES_FILE_LOCATIONS = ".ci/ci.properties"
+                }
+            }
+        }
+
+        // Убеждаемся, что тэг есть. В случае, если Jenkins не подсунул его автоматически (что обычная практика) - пробуем узнать сами.
+        stage('Prepare: Resolve build tag') {
+            steps {
+                script {
+                    if (env.TAG_NAME == null) {
+                        env.TAG_NAME = sh(returnStdout: true, script: "git tag --points-at HEAD").trim()
+                    }
+                }
+            }
+        }
+
+        // Простая сборка приложения "На каждый коммит", просто проверить что собирается
+        stage('Build: Regular') {
+            steps {
+                // TODO: Костыль. Надо скрипты адаптировать под сборку "На каждый коммит" и "Для релизов"
+                runGroovy 'docker_build'
+            }
+        }
+
+        // Дополнительная сборка для релизов
+        stage('Build: Release Binaries And Deploy Image') {
+            when {
+                tag "release-*"
+            }
+            steps {
+                script {
+                    // Пример смены набора докерфайлов, которые используются по-умолчанию
+                    env.CI_DOCKER_FILES_PRESET="release"
+                }
+                runGroovy 'docker_build'
+            }
+        }
+
+        // Пушим собранный образ в Docker Registry
+        stage('Publish: Publish a docker image') {
+            when {
+                tag "release-*"
+            }
+            steps {
+                // Переопределяем параметры скрипта из переменных окружения сборщика
+                script {
+                    env.CI_DOCKER_REGISTRY_USERNAME = env.GITEA_USER
+                    env.CI_DOCKER_REGISTRY_PASSWORD = env.GITEA_TOKEN
+                }
+
+                runGroovy 'docker_push'
+            }
+        }
+
+        // Создаем в Gitea релиз с бинарями
+        stage('Publish: Create gitea release') {
+            when {
+                tag "release-*"
+            }
+            steps {
+                // Переопределяем параметры скрипта из переменных окружения сборщика
+                script {
+                    env.CI_DOCKER_REGISTRY_USERNAME = env.GITEA_USER
+                    env.CI_DOCKER_REGISTRY_PASSWORD = env.GITEA_TOKEN
+                    env.CI_GITEA_ORIGIN = env.GIT_URL
+                    env.CI_GITEA_TOKEN = env.GITEA_TOKEN
+                }
+
+                runGroovy 'docker_gitea_release_publish'
+            }
+        }
+
+        // Добавляем SSH профиль для последующего деплоя и проверяем его
+        stage('Prepare: Configuring SSH profile') {
+            when {
+                tag "release-*"
+            }
+            steps {
+                // Переопределяем параметры скрипта из переменных окружения сборщика
+                script {
+                    env.CI_DEPLOY_SSH_PROFILE_PRIVATE_KEY_BASE64 = env.SSH_KEY_BASE64
+                }
+
+                runGroovy 'deploy_ssh_profile_setup'
+            }
+        }
+
+        // Подключаемся к серверу по SSH, обновляем тэг образа и перезапускаем
+        stage('Deploy: Update PROD docker-compose environment tag') {
+            when {
+                tag "release-*"
+            }
+            steps {
+                runGroovy 'deploy_compose_via_ssh'
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f7ea871
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# Universal CI/CD Scripts
+
+CI/CD скрипты на груви для большинства проектов tswf.
+
+# Назначение
+
+Настройка всей сборки одним файлом - `ci.properties`
+
+# Как работает
+
+* Jenkins поднимает DooD контейнер (можно в целом заменить на DinD с SysBOX со временем), с груви и докером. 
+  * CI скрипты на груви:
+    * Выполняют сборку проекта в Docker
+    * Если установлены специальные релизные тэги, то создает релиз в Gitea с нужными артефактами
+    * Если установлены специальные деплой-тэги, то деплоит через обновление тэга в docker-compose файле по SSH на целевом сервере нужный образ.
+
+
+# Тэги
+Понимание того, что не нужно ограничиваться простым билдом приходит из установленных тэгов. 
+
+По-умолчанию тэги бывают двух видов:
+
+## Релизные тэги. 
+
+Начинаются с префикса `release-`, или того, который пользователь переопределит в `.ci.properties`
+
+Если при сборке пайплайн видит, что установлен релизный тэг, то он постарается создать к собранному коммиту релиз в Gitea, а также запушить собранный образ Docker в указанную в `.ci.properties`registry.
+
+## Деплой тэги
+
+Начинаются с префикса `deploy-`, или переопределенных в `.ci.properties`
+
+Если при сборке коммита есть такой тэг - собранный докер образ будет опубликован и прописан в `docker-compose` через SSH файл согласно настройкам в `.ci.properies`
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..0b7000b
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,48 @@
+plugins {
+    id 'java-library'
+    id 'groovy'
+    id 'maven-publish'
+}
+
+group project.artifact_group
+version project.artifact_version
+
+java {
+    withSourcesJar()
+    sourceCompatibility = targetCompatibility = project.java_min_version
+}
+
+repositories {
+    mavenLocal()
+    mavenCentral()
+    jcenter()
+    maven { url 'https://jitpack.io' }
+    maven { url 'https://git.tswf.io/api/packages/public-repos/maven'}
+}
+
+dependencies {
+    api group: "io.tswf.groovy.better-groovy", name: "better-groovy-scripting-shell", version: project.better_groovy_scripting_shell_version
+    api group: "io.tswf.groovy.better-groovy", name: "better-groovy-scripting-gitea", version: project.better_groovy_scripting_gitea_version
+}
+
+// Apply to all groovy source sets
+tasks.withType(GroovyCompile).each {task ->
+    task.doFirst {
+        // Append compile classpath to groovy classpath to access compile classpath dependencies in compiler configuration script
+        it.groovyClasspath += it.classpath
+
+        // Transfers all groovyc options to compiler configuration script as system properties that can be accessed via System.getProperty("name") from the script file
+        for (def property : project.properties) {
+            if (property.key.startsWith("groovycOption")) {
+                task.groovyOptions.forkOptions.jvmArgs += "-D${property.key}=${property.value}" as String
+            }
+        }
+    }
+
+    task.groovyOptions.javaAnnotationProcessing = true
+    task.options.encoding = "UTF-8"
+    task.groovyOptions.configurationScript = file('gradle/config/groovyc-static.groovy')
+}
+
+compileJava.options.encoding = project.source_files_encoding
+
diff --git a/ci.example.properties b/ci.example.properties
new file mode 100644
index 0000000..6c9baba
--- /dev/null
+++ b/ci.example.properties
@@ -0,0 +1,35 @@
+# Git
+    ci.git.tag.deploy.prefixes=release-, deploy-
+    ci.git.tag.release.prefixes=release-
+
+# Docker
+    ci.docker.files.default=Deploy.Dockerfile
+    ci.docker.files.release=Binaries.Extra.Dockerfile, Deploy.Dockerfile
+    ci.docker.build.additional-args=--build-arg ARG1=${ENV1} --build-arg ARG2=${ENV2}
+
+## SSH
+    ci.deploy.ssh.profile=deploy-ssh-profile
+    ci.deploy.ssh.host=your.domain.or.ip
+    ci.deploy.ssh.port=22
+    ci.deploy.ssh.username=your_login
+
+# Docker Push
+    ci.docker.image.push.files=Deploy.Dockerfile
+    ci.docker.image.base.name=your.registry/your-image
+    ci.docker.registry=your.registry
+    ci.docker.registry.username=your_registry_username
+    ci.docker.registry.password=your_registry_password
+
+## Docker-Compose Deploy
+    ci.deploy.docker-compose.base-command=docker compose
+    ci.deploy.docker-compose.dir=/path/to/your/docker-compose
+    ci.deploy.docker-compose.filename=docker-compose.yml
+
+# Gitea
+    ci.gitea.host=git.tswf.io
+
+## Releases
+    ci.release.artifacts.dockerfiles=Binaries.Extra.Dockerfile
+    ci.release.artifacts.is-required=true
+    ci.release.artifacts.binaries.libc.dockerfile.grab=/build/app.linux-386
+    ci.release.artifact.app.linux-386.name=my-application-name.linux-386
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..2a4dcc8
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,45 @@
+# Artifact Settings
+    artifact_group=io.tswf.ci
+    artifact_id=universal-ci-cd-scripts
+    artifact_version=1.0.0-SNAPSHOT
+
+# Dependencies
+    # Better Groovy
+    better_groovy_scripting_gitea_version=2.0.2-SNAPSHOT
+    better_groovy_scripting_shell_version=2.0.2-SNAPSHOT
+
+# Compilation settings
+    java_min_version=1.8
+    source_files_encoding=UTF-8
+
+# Groovy AST Settings
+
+    ## Apply static compilation to all project
+    groovycOptionGlobalCompileStaticTransform=true
+
+    ## Use GString as annotation members, needs to provide @Column(name = "some_name_${SOME_CONSTANT}) usage
+    groovycOptionGlobalGStringInAnnotationsTransform=true
+
+    ## Call named method variants directly without map-parameterized generated proxy method
+    groovycOptionGlobalNamedVariantsDirectCallsTransform=true
+
+    ## Use to add default paramertes cto named variant calls via map (for Groovy 3xx)
+    groovycOptionGlobalNamedVariantsDefaultsTransform=false
+
+    ## Allow extensions in same sourceset (Exprerimental)
+    groovycOptionSameSourceExtensions=true
+
+    ## Make @NullSafe annotations working
+    groovycOptionGlobalNullsafeChecks=true
+
+    ## Make @CheckedMapConstructor annotation working
+    groovycOptionGlobalCheckMapConstructors=true
+
+    ## Enable earler silent static type checking (may break static compilation!)
+    groovycOptionSilentTypeChecking=false
+
+    ## Automatic checks slf4 log levels in runtime. E.g. log.debug("blablabla") will be if (log.isDebugEnabled()) { log.debug("blablabla") }
+    groovycOptionSLF4JLevels=true
+
+    # Application settings
+    applicationMainClassName=none
\ No newline at end of file
diff --git a/gradle/config/groovyc-static.groovy b/gradle/config/groovyc-static.groovy
new file mode 100644
index 0000000..c0ee0ab
--- /dev/null
+++ b/gradle/config/groovyc-static.groovy
@@ -0,0 +1,63 @@
+import groovy.transform.CompileStatic
+import io.tswf.groovy.bettergroovy.transforms.annotationgstrings.GStringsInAnnotations
+import io.tswf.groovy.bettergroovy.transforms.directnamedargs.DirectNamedArgsCall
+import io.tswf.groovy.bettergroovy.transforms.namedvariantdefaults.NamedVariantDefaults
+import io.tswf.groovy.bettergroovy.transforms.scan.ScanSourceSet
+import io.tswf.groovy.bettergroovy.transforms.extensions.SilentStaticTypeChecker
+import io.tswf.groovy.bettergroovy.transforms.nullsafe.NullSafeVariables
+import io.tswf.groovy.bettergroovy.transforms.validation.checkedmapconstructor.CheckMapConstructors
+import io.tswf.groovy.bettergroovy.transforms.extensions.SameSourceSetExtensionMethods
+
+withConfig(configuration) {
+    ast(ScanSourceSet)
+
+    if (getBooleanProperty("groovycOptionSilentTypeChecking")) {
+        ast(SilentStaticTypeChecker)
+    }
+
+
+    if (getBooleanProperty("groovycOptionSameSourceExtensions")) {
+        ast(SameSourceSetExtensionMethods)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalGStringInAnnotationsTransform")) {
+        ast(GStringsInAnnotations)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalNamedVariantsDirectCallsTransform")) {
+        ast(DirectNamedArgsCall)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalNamedVariantsDefaultsTransform")) {
+        ast(NamedVariantDefaults)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalNullsafeChecks")) {
+        ast(NullSafeVariables)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalCheckMapConstructors")) {
+        ast(CheckMapConstructors)
+    }
+
+    if (getBooleanProperty("groovycOptionSLF4JLevels")) {
+        ast(io.tswf.groovy.bettergroovy.transforms.slf4j.SLF4JLevels)
+    }
+
+    if (getBooleanProperty("groovycOptionGlobalCompileStaticTransform")) {
+        ast(CompileStatic)
+    }
+}
+
+boolean getBooleanProperty(String featureName) {
+    def stringProperty = getStringProperty(featureName)
+    if (stringProperty != null) {
+        return stringProperty.equalsIgnoreCase("true")
+    }
+
+    return null
+}
+
+String getStringProperty(String propertyName) {
+    return System.getProperty(propertyName)
+}
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..01b8bf6
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..0e044e8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Jul 23 20:37:40 CDT 2018
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..cccdd3d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+  cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..f955316
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..c47844a
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = artifact_id
+
diff --git a/src/main/groovy/script/deploy_compose_via_ssh.groovy b/src/main/groovy/script/deploy_compose_via_ssh.groovy
new file mode 100644
index 0000000..8b0696d
--- /dev/null
+++ b/src/main/groovy/script/deploy_compose_via_ssh.groovy
@@ -0,0 +1,85 @@
+package script
+
+import util.CIProperties
+import util.DockerImageNames
+import util.DockerTags
+import util.Dockerfiles
+
+/**
+ * Скрип для деплоя докер образа на сервер посредством обновления docker-compose файла и его перезапуска.
+ */
+
+@GrabResolver(name='gitea', root='https://git.tswf.io/api/packages/public-repos/maven')
+@Grapes([
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-shell', version='2.0.2-SNAPSHOT', changing = true),
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-gitea', version='2.0.2-SNAPSHOT', changing = true)
+])
+import util.GitTags
+import util.GlobalProperties
+import util.ScriptLog
+
+GlobalProperties.loadGlobalProperties()
+
+println """
+#############################################################################
+                       DEPLOY via docker-compose script
+#############################################################################
+"""
+
+def deployGitTags = GitTags.getDeployTags()
+
+if (deployGitTags.isNullOrEmpty()) {
+    ScriptLog.printf "There is no tags to remote restart."
+    System.exit(1)
+}
+
+def deployDockerfiles = Dockerfiles.getDeployDockerfiles()
+
+def dockerComposeBaseCommand = CIProperties.findProperty("deploy.docker-compose.base-command")
+    .orElse("docker compose")
+
+def dockerComposeDirectory = CIProperties.getProperty("deploy.docker-compose.dir")
+
+def dockerComposeFileName = CIProperties.findProperty("deploy.docker-compose.filename")
+    .orElse("docker-compose.yml")
+
+def sshDeployProfile = CIProperties.findProperty("deploy.ssh.profile")
+    .orElse("deploy")
+
+if (deployGitTags.size() > 1) {
+    throw new IllegalStateException("Can not deploy more than one git tag via docker-compose. Found multiple deploy tags: ${deployGitTags}")
+}
+
+def deployGitTag = deployGitTags.first()
+
+ScriptLog.printf "Starting, total ${deployDockerfiles.size()} dockerfiles marked to be deployed... (tag: ${deployGitTag}, dockerfiles: ${deployDockerfiles})"
+
+for (def dockerfileName : deployDockerfiles) {
+    ScriptLog.printf "Deploying image for dockerfile named '${dockerfileName}' via ssh profile ${sshDeployProfile}"
+
+    def dockerImageToUpdateTagBaseName = DockerImageNames.getImageName(dockerfileName)
+
+    ScriptLog.printf "Docker image ${dockerImageToUpdateTagBaseName} base name will be used for dockerfile named '${dockerfileName}'"
+
+    def dockerReleaseTagToSetup = DockerTags.getDockerTagPostfixForDockerfile(
+        GitTags.sanitizeTagFromPrefixes(deployGitTag, GitTags.deployPrefixes)
+    )
+
+    ScriptLog.printf "Updating docker-compose image tag to '${dockerReleaseTagToSetup}' in service '${dockerImageToUpdateTagBaseName}' in file '${dockerComposeFileName}' located in directory '${dockerComposeDirectory}'..."
+
+    sh """ ssh -tt ${sshDeployProfile} "\\
+       cd '${dockerComposeDirectory}' && \\
+       sed -i 's|image: ${dockerImageToUpdateTagBaseName}:.*|image: ${dockerImageToUpdateTagBaseName}:${dockerReleaseTagToSetup}|g' '${dockerComposeFileName}'\\
+       "
+    """
+
+    ScriptLog.printf "Service tag updated, restarting compose..."
+
+    sh """ ssh -tt ${sshDeployProfile} "\\
+       cd '${dockerComposeDirectory}' && \\
+       ${dockerComposeBaseCommand} -f '${dockerComposeFileName}' up -d \\
+       "
+    """
+
+    ScriptLog.printf "Deploying of docker tag '${dockerReleaseTagToSetup}' successfully completed!"
+}
\ No newline at end of file
diff --git a/src/main/groovy/script/deploy_ssh_profile_setup.groovy b/src/main/groovy/script/deploy_ssh_profile_setup.groovy
new file mode 100644
index 0000000..f3ea797
--- /dev/null
+++ b/src/main/groovy/script/deploy_ssh_profile_setup.groovy
@@ -0,0 +1,69 @@
+package script
+
+/**
+ * Скрипт для начальной конфигурации SSH профиля для деплоя
+ * Создает профиль для подключения к стенду, а также настраивает пользователя для этих целей.
+ */
+
+@GrabResolver(name='gitea', root='https://git.tswf.io/api/packages/public-repos/maven')
+@Grapes([
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-shell', version='2.0.2-SNAPSHOT', changing = true),
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-gitea', version='2.0.2-SNAPSHOT', changing = true)
+])
+import util.GlobalProperties
+import util.ScriptLog
+
+println """
+#############################################################################
+                          SSH Profile Setup Script
+#############################################################################
+"""
+
+
+GlobalProperties.loadGlobalProperties()
+
+ScriptLog.printf "Creating .ssh directory..."
+
+def sshDir = new File("${System.getProperty("user.home")}/.ssh")
+sshDir.mkdirsUnsafe()
+
+// Записываем приватный ключ
+
+ScriptLog.printf "Writing ssh private key..."
+
+def sshPrivateKey = sshDir.subFile("id_rsa")
+
+//TODO: может быть тут посолить?
+def sshPrivateKeyBase64Content = System.getGlobalProperty("ci.deploy.ssh.profile.private-key-base64").removeAll(' ')
+def sshPrivateKeyDecodedContent = Base64.decoder.decode(sshPrivateKeyBase64Content)
+
+sshPrivateKey.bytes = sshPrivateKeyDecodedContent
+
+sh 'chmod 400 ~/.ssh/id_rsa'
+
+// Записываем ssh config
+
+ScriptLog.printf "Writing ssh config..."
+
+def sshProfileName = System.getGlobalProperty("ci.deploy.ssh.profile")
+def sshHost = System.getGlobalProperty("ci.deploy.ssh.host")
+def sshPort = System.getGlobalProperty("ci.deploy.ssh.port")
+def sshUsername = System.getGlobalProperty("ci.deploy.ssh.username")
+
+def sshConfigFile = sshDir.subFile("config")
+sshConfigFile.text = """
+Host ${sshProfileName}
+    HostName ${sshHost}
+    User ${sshUsername}
+    Port ${sshPort}
+""".stripIndent(false)
+
+
+// Выполняем тестовое подключение к SSH, чтобы убедиться в корректности настройки.
+
+ScriptLog.printf "Validating ssh configuration..."
+
+sh "ssh ${sshProfileName} -o StrictHostKeyChecking=no -tt 'echo Testing connection!'"
+
+ScriptLog.printf "SSH profile configured successfully!"
+
diff --git a/src/main/groovy/script/docker_build.groovy b/src/main/groovy/script/docker_build.groovy
new file mode 100644
index 0000000..bfe0385
--- /dev/null
+++ b/src/main/groovy/script/docker_build.groovy
@@ -0,0 +1,39 @@
+package script
+
+import util.Dockerfiles
+import util.DockerBuildCommandFactory
+import util.GlobalProperties
+import util.ScriptLog
+
+/**
+ * Скрипт для тестового прогона сборки докер. Просто проверить, что собирается.
+ */
+
+@GrabResolver(name='gitea', root='https://git.tswf.io/api/packages/public-repos/maven')
+@Grapes([
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-shell', version='2.0.2-SNAPSHOT', changing = true),
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-gitea', version='2.0.2-SNAPSHOT', changing = true)
+])
+_
+
+println """
+#############################################################################
+                          Build Docker Image Script
+#############################################################################
+"""
+
+GlobalProperties.loadGlobalProperties()
+
+// Проверяем, что установлен docker
+if (System.findExecutablesInPath(['docker']).isEmpty())
+    throw new FileNotFoundException("Can't find installed docker-compose at that system!")
+
+ScriptLog.printf "Building docker image without tags..."
+
+for (def dockerfileName : Dockerfiles.getPresetDockerfiles()) {
+    def dockerCommand = DockerBuildCommandFactory.getBuildCommand(dockerfileName)
+    println "Using dockerfile '${dockerfileName}', full command: '$dockerCommand'"
+    sh dockerCommand
+}
+
+ScriptLog.printf "Docker image successfully built!"
diff --git a/src/main/groovy/script/docker_gitea_release_publish.groovy b/src/main/groovy/script/docker_gitea_release_publish.groovy
new file mode 100644
index 0000000..bc4b916
--- /dev/null
+++ b/src/main/groovy/script/docker_gitea_release_publish.groovy
@@ -0,0 +1,143 @@
+package script
+
+/**
+ *
+ * Скрипт для создания или обновления релиза в gitea для всех релизных тегов текущего коммита.
+ * Ищет описание релиза в специальной папке, после чего добавляет его к релизу, если находит.
+ *
+ * Собирает докер образ, после чего из него копирует указанные как артефакты файлы и прикрепляет к релизу.
+ *
+ * Для работы требуется токен API Gitea и список нужных файлов, либо флаг того, что список может быть пустым.
+ */
+
+
+@GrabResolver(name='gitea', root='https://git.tswf.io/api/packages/public-repos/maven')
+@Grapes([
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-shell', version='2.0.2-SNAPSHOT', changing = true),
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-gitea', version='2.0.2-SNAPSHOT', changing = true)
+])
+import io.tswf.gitea.parser.repository.GiteaRepositoryMetadataParser
+import io.tswf.groovy.bettergroovy.scripting.v2.git.Git
+import io.tswf.groovy.bettergroovy.scripting.v2.gitea.Gitea
+import util.CIProperties
+import util.ReleaseArtifactNames
+import util.Dockerfiles
+import util.DockerBuildCommandFactory
+import util.GitTags
+import util.GlobalProperties
+import util.ReleaseArtifacts
+import util.ReleaseDescriptionReader
+import util.ScriptLog
+
+println """
+#############################################################################
+                       Publish Release Artifacts Script
+#############################################################################
+"""
+
+GlobalProperties.loadGlobalProperties()
+
+def dockerFiles = Dockerfiles.getReleaseDockerfiles()
+
+def isAnyArtifactRequiredForRelease = CIProperties.findProperty("gitea.release.artifacts.is-required")
+    .map { it.toBoolean() }
+    .orElse(true)
+
+def releaseTags = GitTags.getReleaseTags()
+
+if (releaseTags.isNullOrEmpty()) {
+    ScriptLog.printf "There is no release tags to publish!"
+    System.exit(1)
+}
+
+def giteaHost = CIProperties.findProperty("gitea.origin") // В рамках обратной совместимости
+    .orElseGet {
+        CIProperties.getProperty("gitea.host") // Актуальное название
+    }
+
+def giteaToken = CIProperties.getProperty("gitea.token")
+
+def giteaRepositoryParser = new GiteaRepositoryMetadataParser()
+def giteaRepositoryMetadata = giteaRepositoryParser.parseRepositoryMetadataFromOrigin(giteaHost)
+
+def artifactLocalFiles = new ArrayList<File>()
+
+for (def dockerfileName : dockerFiles) {
+    ScriptLog.printf "Collecting artifacts from dockerfile named '${dockerfileName}'"
+
+    // Собираем докер образ и поднимаем контейнер, чтобы далее извлечь из него нужные артефакты.
+    def temporaryDockerImage = Random.randomString(40)
+    def temporaryDockerContainerName = "build_in_docker_${Random.randomString(25)}"
+
+    // Подчищаем за собою
+    addShutdownHook {
+        println "Removing temporary docker container '${temporaryDockerContainerName}' and image '${temporaryDockerImage}'"
+        sh "docker rm -f ${temporaryDockerContainerName}"
+        sh "docker image rm -f ${temporaryDockerImage}"
+    }
+
+    def temporaryBuildDir = new File("./gitea-release-publication/tmp/build/${Random.randomString(10)}/")
+    temporaryBuildDir.mkdirsUnsafe()
+    temporaryBuildDir.deleteOnExit()
+
+    def dockerBuildCommand = DockerBuildCommandFactory.getBuildCommand(dockerfileName, "-t ${temporaryDockerImage}")
+    sh dockerBuildCommand
+    sh "docker create -it --name ${temporaryDockerContainerName} ${temporaryDockerImage} sh"
+
+    def artifactsToPublish = ReleaseArtifacts.getArtifacts(dockerfileName)
+
+    if (artifactsToPublish.isEmpty()) {
+        ScriptLog.printf "There is no artifacts was configured for dockerfile named '${dockerfileName}'"
+    } else {
+        ScriptLog.printf "Copying artifacts for dockerfile named '${dockerfileName}': ${artifactsToPublish.join('\n')}"
+    }
+
+    // Копируем все артефакты для публикации из временного докер контейнера
+    artifactsToPublish.stream()
+        .map { artifactToPublishAbsolutePath ->
+            def artifactSimpleName = artifactToPublishAbsolutePath.split("/").last()
+
+            def artifactLocalFile = temporaryBuildDir.subFile(artifactSimpleName)
+            sh "docker cp ${temporaryDockerContainerName}:${artifactToPublishAbsolutePath} ${artifactLocalFile.path}"
+
+            artifactLocalFile
+        }
+
+        // Переименовываем файл, если это настроено
+        .map { artifactFile ->
+            ReleaseArtifactNames.getNewArtifactName(artifactFile, dockerfileName)
+                .map { newName ->
+                    def newFile = artifactFile.parentFile.subFile(newName)
+                    ScriptLog.printf "Renaming artifact '${artifactFile.absoluteCanonicalFile.path}' to '${newName}' for dockerfile named '${dockerfileName}'"
+                    artifactFile.renameTo(newFile)
+
+                    newFile
+                }
+                .orElse(artifactFile)
+        }
+
+        .forEach {
+            artifactLocalFiles << it
+        }
+}
+
+ScriptLog.printf "Total ${artifactLocalFiles.size()} artifacts collected: ${artifactLocalFiles}"
+
+if (artifactLocalFiles.isEmpty() && isAnyArtifactRequiredForRelease) {
+    ScriptLog.printf "Release artifacts required, but no one found!"
+    System.exit(1)
+}
+
+def giteaRepository = Gitea.getCIRepositoryUtilityV1(giteaToken, giteaRepositoryMetadata)
+
+// Создаем релизы в гитее
+releaseTags.forEach { releaseTag ->
+    def releaseName = GitTags.sanitizeTagFromPrefixes(releaseTag, GitTags.getReleasePrefixes())
+    def releaseCommitSha = System.findGlobalProperty("ci.gitea.release.commit.short-sha")
+        .orElse(Git.commitShortSha)
+
+    def releaseDescription = ReleaseDescriptionReader.readDescription(releaseName)
+        .orElse("")
+
+    giteaRepository.createOrUpdateRelease(releaseTag, releaseCommitSha, releaseName, releaseDescription, artifactLocalFiles)
+}
\ No newline at end of file
diff --git a/src/main/groovy/script/docker_push.groovy b/src/main/groovy/script/docker_push.groovy
new file mode 100644
index 0000000..9a9faf0
--- /dev/null
+++ b/src/main/groovy/script/docker_push.groovy
@@ -0,0 +1,74 @@
+package script
+
+import util.DockerImageNames
+
+/**
+ * Скрипт для публикации докер образа при сборке.
+ */
+
+@GrabResolver(name='gitea', root='https://git.tswf.io/api/packages/public-repos/maven')
+@Grapes([
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-shell', version='2.0.2-SNAPSHOT', changing = true),
+        @Grab(group='io.tswf.groovy.better-groovy', module='better-groovy-scripting-gitea', version='2.0.2-SNAPSHOT', changing = true)
+])
+import util.DockerLogin
+import util.DockerBuildCommandFactory
+import util.Dockerfiles
+import util.GitTags
+import util.GlobalProperties
+import util.ScriptLog
+
+println """
+#############################################################################
+                          Push Docker Images Script
+#############################################################################
+"""
+
+GlobalProperties.loadGlobalProperties()
+
+def gitReleaseTags = GitTags.getDeployTags()
+
+if (gitReleaseTags.isNullOrEmpty()) {
+    ScriptLog.printf "There is no release tags found!"
+    System.exit(1)
+}
+else
+    ScriptLog.printf "Found git release tags ${gitReleaseTags}"
+
+ScriptLog.printf "Starting docker publishing..."
+
+DockerLogin.perform()
+
+def dockerfilesToPush = System.findGlobalPropertyList("ci.docker.image.push.files").orElse([])
+
+gitReleaseTags.each { gitTag ->
+    Dockerfiles.getPresetDockerfiles().each { dockerfileName ->
+        if (!dockerfilesToPush.isEmpty() && dockerfileName !in dockerfilesToPush) {
+            ScriptLog.printf "Skipping push of dockerfile '${dockerfileName}' because it is not in push list: ${dockerfilesToPush}"
+            return
+        }
+
+        ScriptLog.printf "Building docker tag: '${gitTag}' with dockerfile named '${dockerfileName}'"
+
+        def dockerImageBaseName = DockerImageNames.getImageName(dockerfileName)
+
+        // Для докер-тегов образов используем гит-теги с отброшенным префиксом
+        def imageBaseTag = gitTag.replace(GitTags.getPrefix(), "")
+        def imageTag = DockerImageNames.getImageTag(imageBaseTag, dockerfileName)
+
+        def imageFullName = "${dockerImageBaseName}:${imageTag}"
+
+        ScriptLog.printf "Full docker image name will be '${imageFullName}'"
+
+        def dockerBuildCommand = DockerBuildCommandFactory.getBuildCommand(dockerfileName, "-t ${imageFullName}")
+
+        ScriptLog.printf "Executing: '${dockerBuildCommand}'"
+
+        sh dockerBuildCommand
+        sh "docker push '${imageFullName}'"
+
+        ScriptLog.printf "Docker tag ${imageTag} build and push completed with full image name: '${imageFullName}'"
+    }
+}
+
+ScriptLog.printf "Publishing done!"
\ No newline at end of file
diff --git a/src/main/groovy/util/CIProperties.groovy b/src/main/groovy/util/CIProperties.groovy
new file mode 100644
index 0000000..78de74e
--- /dev/null
+++ b/src/main/groovy/util/CIProperties.groovy
@@ -0,0 +1,25 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class CIProperties {
+
+    public static final String CI_PROPERTIES_PREFIX = "ci"
+
+    static String getProperty(String propertyName) {
+        return findProperty(propertyName).orElseThrow { new IllegalStateException("Required CI property named '${propertyName}' was not found!") }
+    }
+
+    static Optional<String> findProperty(String propertyName) {
+        return System.findGlobalProperty("${CI_PROPERTIES_PREFIX}.${propertyName}")
+    }
+
+    static List<String> getListProperty(String propertyName) {
+        return findListProperty(propertyName).orElseThrow { new IllegalStateException("Required CI property named '${propertyName}' was not found!") }
+    }
+
+    static Optional<List<String>> findListProperty(String propertyName) {
+        return System.findGlobalPropertyList("${CI_PROPERTIES_PREFIX}.${propertyName}")
+    }
+}
diff --git a/src/main/groovy/util/DockerBuildCommandFactory.groovy b/src/main/groovy/util/DockerBuildCommandFactory.groovy
new file mode 100644
index 0000000..3257029
--- /dev/null
+++ b/src/main/groovy/util/DockerBuildCommandFactory.groovy
@@ -0,0 +1,26 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class DockerBuildCommandFactory {
+
+    static String getBuildAdditionalArgs(String dockerfileName) {
+        def propertyNames = [
+            "docker.build.${dockerfileName.toLowerCase()}.additional-args",
+            "docker.build.additional-args"
+        ]
+
+        return propertyNames.stream()
+            .map { CIProperties.findProperty("ci.docker.build.additional-args").orNull() }
+            .filter { it != null }
+            .findFirst()
+            .orElse("")
+    }
+
+    static String getBuildCommand(String dockerfileName, String additionalArgs = "", String context = ".") {
+        def dockerCommand = "docker build ${context} -f '${dockerfileName}' ${additionalArgs} ${getBuildAdditionalArgs(dockerfileName)}"
+        return dockerCommand
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/groovy/util/DockerImageNames.groovy b/src/main/groovy/util/DockerImageNames.groovy
new file mode 100644
index 0000000..e8a0b20
--- /dev/null
+++ b/src/main/groovy/util/DockerImageNames.groovy
@@ -0,0 +1,29 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class DockerImageNames {
+
+    static String getImageTag(String baseName, String dockerfile) {
+        def imageTagPostfix = CIProperties.findProperty("docker.image.tag.${dockerfile.toLowerCase()}.postfix").orElse("")
+        return "${baseName}${imageTagPostfix}"
+    }
+
+    static String getImageName(String dockerfileName) {
+        def propertyNames = [
+                "docker.image.${dockerfileName.toLowerCase()}.name",
+                "docker.image.base.name"
+        ]
+
+        return propertyNames.stream()
+            .map { it.toString() }
+            .map {
+                CIProperties.findProperty(it).orNull()
+            }
+            .filter { it != null }
+            .findFirst()
+            .orElseThrow { new NoSuchElementException("There is no one property set: ${propertyNames}") }
+    }
+
+}
diff --git a/src/main/groovy/util/DockerLogin.groovy b/src/main/groovy/util/DockerLogin.groovy
new file mode 100644
index 0000000..20b8e5a
--- /dev/null
+++ b/src/main/groovy/util/DockerLogin.groovy
@@ -0,0 +1,29 @@
+package util
+
+import groovy.transform.CompileStatic
+import groovy.transform.Memoized
+
+@CompileStatic
+class DockerLogin {
+
+    static void perform() {
+        loginDockerInternal()
+    }
+
+    @Memoized
+    private static Object loginDockerInternal() {
+        // Проверяем на всякий случай, что докер вообще установлен
+        if (System.findExecutablesInPath(['docker']).isEmpty())
+            throw new FileNotFoundException("Can't find installed docker-compose at that system!")
+
+        // Логинимся в Registry
+        ScriptLog.printf "Performing login to registry..."
+        def registryName = System.getGlobalProperty("ci.docker.registry")
+        def registryUser = System.getGlobalProperty("ci.docker.registry.username")
+        def registryPassword = System.getGlobalProperty("ci.docker.registry.password")
+
+        sh "docker login $registryName -u $registryUser -p $registryPassword"
+
+        ScriptLog.printf "Login into docker registry '${registryName}' successful!"
+    }
+}
\ No newline at end of file
diff --git a/src/main/groovy/util/DockerTags.groovy b/src/main/groovy/util/DockerTags.groovy
new file mode 100644
index 0000000..d80f36b
--- /dev/null
+++ b/src/main/groovy/util/DockerTags.groovy
@@ -0,0 +1,17 @@
+package util;
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class DockerTags {
+
+    static String getDockerTagForDockerfile(String dockerfileName, String baseTag) {
+        return "${baseTag}${getDockerTagPostfixForDockerfile(dockerfileName)}"
+    }
+
+    static String getDockerTagPostfixForDockerfile(String dockerfileName) {
+        return CIProperties.findProperty("docker.tag.${dockerfileName}.prefix")
+            .orElse("")
+    }
+
+}
diff --git a/src/main/groovy/util/Dockerfiles.groovy b/src/main/groovy/util/Dockerfiles.groovy
new file mode 100644
index 0000000..80b5232
--- /dev/null
+++ b/src/main/groovy/util/Dockerfiles.groovy
@@ -0,0 +1,38 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class Dockerfiles {
+
+    static List<String> getPresetDockerfiles() {
+        def preset = CIProperties.findProperty("docker.files.preset")
+            .orElse("default")
+
+        def presetDockerfiles = CIProperties.findListProperty("docker.files.${preset}")
+            .orElse([])
+
+        if (presetDockerfiles.isEmpty())
+            return CIProperties.findListProperty("docker.files")
+                    .orElse(["Dockerfile"])
+        else
+            return presetDockerfiles
+    }
+
+    static List<String> getReleaseDockerfiles() {
+        return CIProperties.findListProperty("release.artifacts.dockerfiles")
+                .orElseGet {
+                    CIProperties.findListProperty("gitea.release.artifacts.dockerfiles") // Обратная совместимость
+                            .orElseGet {
+                                getPresetDockerfiles()
+                            }
+                }
+    }
+
+    static List<String> getDeployDockerfiles() {
+        return CIProperties.findListProperty("deploy.dockerfile.names")
+                .orElseGet {
+                    getPresetDockerfiles()
+                }
+    }
+}
diff --git a/src/main/groovy/util/GitTags.groovy b/src/main/groovy/util/GitTags.groovy
new file mode 100644
index 0000000..8cca2ba
--- /dev/null
+++ b/src/main/groovy/util/GitTags.groovy
@@ -0,0 +1,44 @@
+package util
+
+import groovy.transform.CompileStatic
+import io.tswf.groovy.bettergroovy.scripting.v2.git.Git
+
+@CompileStatic
+class GitTags {
+    public static final String DEFAULT_RELEASE_TAG_PREFIX = "release-"
+    public static final String DEFAULT_DEPLOY_TAG_PREFIX = DEFAULT_RELEASE_TAG_PREFIX
+
+    static Set<String> getDeployPrefixes() {
+        return CIProperties.findListProperty("git.tag.deploy.prefixes")
+            .orElse([DEFAULT_DEPLOY_TAG_PREFIX])
+            .toSet()
+    }
+
+    static Set<String> getReleasePrefixes() {
+        return CIProperties.findListProperty("git.tag.release.prefixes")
+                .orElse([DEFAULT_RELEASE_TAG_PREFIX])
+                .toSet()
+    }
+
+    static List<String> getTagsWithPrefix(Collection<String> prefixes) {
+        def matchingTags = Git.tags.stream()
+                .filter {tag -> prefixes.any { tag.startsWith(it) }}
+                .toList()
+
+        return matchingTags
+    }
+
+    static List<String> getReleaseTags() {
+        def releasePrefixes = getReleasePrefixes()
+        return getTagsWithPrefix(releasePrefixes)
+    }
+
+    static List<String> getDeployTags() {
+        def deployPrefixes = getDeployPrefixes()
+        return getTagsWithPrefix(deployPrefixes)
+    }
+
+    static String sanitizeTagFromPrefixes(String tag, Collection<String> prefixes) {
+        return tag.removeAll(prefixes)
+    }
+}
diff --git a/src/main/groovy/util/GlobalProperties.groovy b/src/main/groovy/util/GlobalProperties.groovy
new file mode 100644
index 0000000..55b3b66
--- /dev/null
+++ b/src/main/groovy/util/GlobalProperties.groovy
@@ -0,0 +1,39 @@
+package util
+
+import groovy.transform.Memoized
+
+class GlobalProperties {
+
+    @Memoized
+    static loadGlobalProperties() {
+        def propertiesFiles = System.findGlobalPropertyList("${CIProperties.CI_PROPERTIES_PREFIX}.properties.file.locations")
+            .orElse([".ci/ci.properties"])
+            .map { new File(it) }
+
+        if (propertiesFiles.isNotNullOrEmpty()) {
+            propertiesFiles.forEach { propertiesFile ->
+                if (propertiesFile.exists()) {
+                    loadPropertiesFile(propertiesFile)
+                } else {
+                    ScriptLog.printf "Skipping non-existing properties file '${propertiesFile.absoluteCanonicalFile.path}'!"
+                }
+            }
+        }
+    }
+
+    static void loadPropertiesFile(File propertiesFile) {
+        if (propertiesFile == null) {
+            return
+        }
+
+        if (!propertiesFile.exists()) {
+            throw new IOException("Could not find properties file '${propertiesFile}' (with absolute path: '${propertiesFile.absoluteCanonicalFile.path}')!")
+        }
+
+        if (propertiesFile.isDirectory()) {
+            throw new IOException("Directory found instead properties file '${propertiesFile}' (with absolute path: '${propertiesFile.absoluteCanonicalFile.path}')!")
+        }
+
+        System.registerGlobalConfiguration(propertiesFile)
+    }
+}
diff --git a/src/main/groovy/util/ReleaseArtifactNames.groovy b/src/main/groovy/util/ReleaseArtifactNames.groovy
new file mode 100644
index 0000000..aa180c2
--- /dev/null
+++ b/src/main/groovy/util/ReleaseArtifactNames.groovy
@@ -0,0 +1,67 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class ReleaseArtifactNames {
+
+    static List<ArtifactNameResolver> artifactFileNameResolvers = new ArrayList<>()
+
+    static Optional<String> getNewArtifactName(File file, String dockerfileName) {
+        def newArtifactName = artifactFileNameResolvers.stream()
+            .map { it.resolveArtifactNewName(file, dockerfileName) }
+            .filter { it != null }
+            .firstOrNull()
+
+        return Optional.ofNullable(newArtifactName)
+    }
+
+    static void registerArtifactResolver(ArtifactNameResolver resolver) {
+        artifactFileNameResolvers << resolver
+    }
+
+    static {
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("release.artifact.${dockerfile}.${replacePathInvalidChars(file.absoluteCanonicalFile.path)}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("release.artifact.${dockerfile}.${file.name}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("release.artifact.${replacePathInvalidChars(file.absoluteCanonicalFile.path)}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("release.artifact.${file.name}.name").orNull()
+        }
+
+        // В рамках обратной совместимости
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("gitea.release.artifact.${dockerfile}.${replacePathInvalidChars(file.absoluteCanonicalFile.path)}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("gitea.release.artifact.${dockerfile}.${file.name}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("gitea.release.artifact.${replacePathInvalidChars(file.absoluteCanonicalFile.path)}.name").orNull()
+        }
+
+        registerArtifactResolver { file, dockerfile ->
+            CIProperties.findProperty("gitea.release.artifact.${file.name}.name").orNull()
+        }
+    }
+
+    static String replacePathInvalidChars(String fileFullName) {
+        return fileFullName
+                .replace('\\', '_')
+                .replace('/', '_')
+    }
+
+    static interface ArtifactNameResolver {
+        String resolveArtifactNewName(File artifactFile, String dockerfileName)
+    }
+}
\ No newline at end of file
diff --git a/src/main/groovy/util/ReleaseArtifacts.groovy b/src/main/groovy/util/ReleaseArtifacts.groovy
new file mode 100644
index 0000000..04fda30
--- /dev/null
+++ b/src/main/groovy/util/ReleaseArtifacts.groovy
@@ -0,0 +1,23 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class ReleaseArtifacts {
+
+    static List<String> getArtifacts(String dockerFile) {
+        def propertyNames = [
+                "gitea.release.${dockerFile}.artifacts", // В рамках обратной совместимости
+                "gitea.release.artifacts", // В рамках обратной совместимости
+                "release.artifacts.${dockerFile}.grab",
+                "release.artifacts.grab",
+        ]
+
+        return propertyNames.stream()
+            .map { it.toString() }
+            .map { CIProperties.findListProperty(it).orNull() }
+            .filter { it != null }
+            .map { (List<String>) it }
+            .findFirst().orElse([])
+    }
+}
diff --git a/src/main/groovy/util/ReleaseDescriptionReader.groovy b/src/main/groovy/util/ReleaseDescriptionReader.groovy
new file mode 100644
index 0000000..efc5b70
--- /dev/null
+++ b/src/main/groovy/util/ReleaseDescriptionReader.groovy
@@ -0,0 +1,27 @@
+package util
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class ReleaseDescriptionReader {
+
+    static Optional<String> readDescription(String releaseName) {
+        def releaseDescriptionsDir = CIProperties.findProperty("release.descriptions.dir")
+            .orElse(".releases/changelog/")
+            .with {
+                new File(it)
+            }
+
+        def releaseDescriptionFile = releaseDescriptionsDir.subFile("${releaseName}-changelog.md")
+
+        if (releaseDescriptionFile.exists()) {
+            def releaseAbsPath = releaseDescriptionFile.absoluteCanonicalFile.path
+            ScriptLog.printf "Found description file for '$releaseName' release in '$releaseAbsPath'! It will be used for release creation."
+            return Optional.of(releaseDescriptionFile.getText("UTF-8"))
+        } else {
+            ScriptLog.printf "Release description file not found in '${releaseDescriptionFile.absoluteCanonicalFile.path}', falling back to default value..."
+        }
+
+        return Optional.empty()
+    }
+}
diff --git a/src/main/groovy/util/ScriptLog.groovy b/src/main/groovy/util/ScriptLog.groovy
new file mode 100644
index 0000000..3c0feff
--- /dev/null
+++ b/src/main/groovy/util/ScriptLog.groovy
@@ -0,0 +1,8 @@
+package util
+
+class ScriptLog {
+    static void printf(Object text, Object... args) {
+        def callerClassName = new Throwable().getStackTrace()[1].getClassName().split("\\.").last()
+        println "[${callerClassName}]: ${String.format(String.valueOf(text), args)}"
+    }
+}