feat: add digest-gated internal main deployment
Some checks failed
Mostovik Backend CI/CD / Tests and lint (push) Failing after 5s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Has been skipped
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped

This commit is contained in:
2026-08-27 12:49:43 +03:00
parent 7a8e5765ad
commit c884b4e3fd
11 changed files with 728 additions and 648 deletions

View File

@@ -1,446 +1,324 @@
name: CI/CD Pipeline
name: Mostovik Backend CI/CD
on:
push:
branches:
- main
- dev
- "feature/**"
- "codex/**"
# Add main only after the first manual main restore/rollback drill passes.
pull_request:
branches:
- main
- dev
- main
workflow_dispatch:
concurrency:
group: mostovik-backend-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
group: mostovik-backend-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PYTHON_VERSION: "3.11"
REGISTRY_API_URL: "https://registry.dev.nii-ecos.ru/v2/"
REGISTRY_HOST: "registry.dev.nii-ecos.ru"
REGISTRY_NAMESPACE: "${{ github.repository_owner }}"
WEB_IMAGE: "mostovik-backend-web"
CELERY_IMAGE: "mostovik-backend-celery"
CI_GOLDEN_IMAGE: "mostovik-backend-ci-golden"
WEB_GOLDEN_IMAGE: "mostovik-backend-web-golden"
CELERY_GOLDEN_IMAGE: "mostovik-backend-celery-golden"
GOLDEN_TAG: "py311-uv0.7.2"
UV_VERSION: "0.7.2"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
REGISTRY_HOST: registry.dev.nii-ecos.ru
REGISTRY_NAMESPACE: ${{ github.repository_owner }}
WEB_IMAGE: mostovik-backend-web
CELERY_IMAGE: mostovik-backend-celery
CUSTOMER_DEPLOY_HOST: 10.0.10.174
CUSTOMER_DEPLOY_USER: ecos
CUSTOMER_SSH_PROXY_HOST: ecos-proxy@10.10.0.121
jobs:
quality:
name: Quality Gate
name: Tests and lint
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 30
steps:
- name: Checkout code
- name: Checkout exact event revision
uses: actions/checkout@v4.2.2
with:
persist-credentials: false
- name: Run quality gate
run: |
set -euo pipefail
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" .
git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}"
ci_image="mostovik-backend-ci:${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker build \
--file docker/Dockerfile \
--target ci-deps-base \
--tag "${ci_image}" \
.
- name: Free Docker space
run: |
set -euo pipefail
docker system df || true
docker buildx prune --all --force || true
docker builder prune --all --force || true
docker system prune --all --force --volumes || true
docker system df || true
- name: Run quality in golden image
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
SKIP_LINT: ${{ contains(github.event.head_commit.message, '#no_lint') }}
SKIP_TEST: ${{ contains(github.event.head_commit.message, '#no_test') }}
run: |
set -euo pipefail
REGISTRY_PATH="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}"
CI_GOLDEN_REF="${REGISTRY_PATH}/${CI_GOLDEN_IMAGE}"
REGISTRY_USER="${REGISTRY_USER:-${GITHUB_ACTOR}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GITEA_TOKEN:-}}"
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY
export NO_PROXY="${NO_PROXY:-},${REGISTRY_HOST}"
export no_proxy="${no_proxy:-},${REGISTRY_HOST}"
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "REGISTRY_PASSWORD secret is not set and GITEA_TOKEN fallback is empty" >&2
exit 1
fi
echo "${REGISTRY_PASSWORD}" \
| docker login "${REGISTRY_HOST}" \
-u "${REGISTRY_USER}" \
--password-stdin
if ! docker buildx inspect mostovik-builder >/dev/null 2>&1; then
docker buildx create --name mostovik-builder --use
else
docker buildx use mostovik-builder
fi
docker buildx inspect --bootstrap
lockfile_sha="$(sha256sum uv.lock | awk '{ print $1 }')"
image_usable() {
docker run --rm "${CI_GOLDEN_REF}:${GOLDEN_TAG}" \
bash -c "
set -euo pipefail
test -f /app/uv.lock
test \"\$(sha256sum /app/uv.lock | awk '{ print \$1 }')\" = '${lockfile_sha}'
/app/.venv/bin/ruff --version >/dev/null
/app/.venv/bin/python -m pytest --version >/dev/null
"
}
if ! docker buildx imagetools inspect "${CI_GOLDEN_REF}:${GOLDEN_TAG}" >/dev/null 2>&1 || ! image_usable; then
docker buildx prune --all --force || true
docker builder prune --all --force || true
docker buildx build \
-f ./docker/Dockerfile \
--target ci-deps-base \
--push \
-t "${CI_GOLDEN_REF}:${GOLDEN_TAG}" \
-t "${CI_GOLDEN_REF}:latest" \
.
docker pull "${CI_GOLDEN_REF}:${GOLDEN_TAG}"
fi
tar --exclude=.git -cf - . \
tar --exclude=.git --exclude=.venv --exclude=.ruff_cache -cf - . \
| docker run --rm -i \
-e DJANGO_SETTINGS_MODULE=settings.test \
-e SECRET_KEY=test-secret-key-for-ci \
-e SKIP_LINT="${SKIP_LINT}" \
-e SKIP_TEST="${SKIP_TEST}" \
"${CI_GOLDEN_REF}:${GOLDEN_TAG}" \
bash -c '
set -euo pipefail
mkdir -p /workspace
tar -xf - -C /workspace
cd /workspace
export PATH="/app/.venv/bin:${PATH}"
export PYTHONPATH="/workspace/src:${PYTHONPATH:-}"
if [ "${SKIP_LINT}" != "true" ]; then
/app/.venv/bin/ruff check src
/app/.venv/bin/ruff format src --check
fi
if [ "${SKIP_TEST}" != "true" ]; then
/app/.venv/bin/python -m pytest tests --ignore=tests/test_api_inventory_e2e.py -q
/app/.venv/bin/python -m pytest tests/test_api_inventory_e2e.py -q
fi
'
--env DJANGO_SETTINGS_MODULE=settings.test \
--env SECRET_KEY=test-secret-key-for-ci \
"${ci_image}" \
bash -c '
set -euo pipefail
mkdir -p /workspace
tar -xf - -C /workspace
cd /workspace
export PATH="/app/.venv/bin:${PATH}"
export PYTHONPATH="/workspace/src:${PYTHONPATH:-}"
ruff check src
ruff format src --check
python -m pytest tests --ignore=tests/test_api_inventory_e2e.py -q
python -m pytest tests/test_api_inventory_e2e.py -q
'
build_push:
name: Build and Push Images
runs-on: ubuntu-latest
timeout-minutes: 45
build:
name: Build linux/amd64 release images
runs-on: [backend-docker]
timeout-minutes: 60
needs: [quality]
if: needs.quality.result == 'success'
if: ${{ needs.quality.result == 'success' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
outputs:
web_ref: ${{ steps.release.outputs.web_ref }}
celery_ref: ${{ steps.release.outputs.celery_ref }}
web_tag_ref: ${{ steps.release.outputs.web_tag_ref }}
celery_tag_ref: ${{ steps.release.outputs.celery_tag_ref }}
steps:
- name: Check whether image build is required
- name: Checkout exact event revision
uses: actions/checkout@v4.2.2
with:
persist-credentials: false
- name: Build once and publish immutable digests
id: release
env:
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" != "push" ]; then
echo "Skip image build for ${GITHUB_EVENT_NAME}"
exit 0
fi
if [ "${GITHUB_REF}" != "refs/heads/dev" ]; then
echo "Skip image build for ${GITHUB_REF}"
exit 0
fi
case "${HEAD_COMMIT_MESSAGE:-}" in
*"#no_image"*)
echo "Skip image build because commit message contains #no_image"
exit 0
;;
esac
echo "Image build is required for ${GITHUB_REF}"
- name: Checkout code
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' && !contains(github.event.head_commit.message, '#no_image') }}
run: |
set -euo pipefail
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" .
git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}"
- name: Free Docker build space
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' && !contains(github.event.head_commit.message, '#no_image') }}
run: |
set -euo pipefail
docker system df || true
docker buildx prune --all --force || true
docker builder prune --all --force || true
docker system prune --all --force --volumes || true
docker system df || true
- name: Build and push dev images
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' && !contains(github.event.head_commit.message, '#no_image') }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-branch}}"
BRANCH_TAG=$(printf '%s' "${BRANCH}" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's#[/[:space:]]+#-#g; s#[^a-z0-9_.-]+#-#g; s#^-+##; s#-+$##')
BRANCH_TAG="${BRANCH_TAG:-branch}"
BRANCH_TAG=$(printf '%.120s' "${BRANCH_TAG}")
SHA_SHORT=$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)
REGISTRY_PATH="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}"
WEB_REF="${REGISTRY_PATH}/${WEB_IMAGE}"
CELERY_REF="${REGISTRY_PATH}/${CELERY_IMAGE}"
WEB_GOLDEN_REF="${REGISTRY_PATH}/${WEB_GOLDEN_IMAGE}"
CELERY_GOLDEN_REF="${REGISTRY_PATH}/${CELERY_GOLDEN_IMAGE}"
REGISTRY_USER="${REGISTRY_USER:-${GITHUB_ACTOR}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GITEA_TOKEN:-}}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY
export NO_PROXY="${NO_PROXY:-},${REGISTRY_HOST}"
export no_proxy="${no_proxy:-},${REGISTRY_HOST}"
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "REGISTRY_PASSWORD secret is not set and GITEA_TOKEN fallback is empty" >&2
if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_PASSWORD:-}" ]; then
echo "REGISTRY_USERNAME and REGISTRY_PASSWORD secrets are required" >&2
exit 1
fi
echo "${REGISTRY_PASSWORD}" \
| docker login "${REGISTRY_HOST}" \
-u "${REGISTRY_USER}" \
--password-stdin
printf '%s' "${REGISTRY_PASSWORD}" \
| docker login "${REGISTRY_HOST}" --username "${REGISTRY_USERNAME}" --password-stdin
WEB_TAGS=(
-t "${WEB_REF}:dev-${SHA_SHORT}"
-t "${WEB_REF}:dev"
)
CELERY_TAGS=(
-t "${CELERY_REF}:dev-${SHA_SHORT}"
-t "${CELERY_REF}:dev"
)
if ! docker buildx inspect mostovik-builder >/dev/null 2>&1; then
docker buildx create --name mostovik-builder --use
else
docker buildx use mostovik-builder
fi
builder="mostovik-backend-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker buildx create --name "${builder}" --driver docker-container --use
cleanup() {
docker buildx rm "${builder}" >/dev/null 2>&1 || true
rm -f web-metadata.json celery-metadata.json
}
trap cleanup EXIT
docker buildx inspect --bootstrap
lockfile_sha="$(sha256sum uv.lock | awk '{ print $1 }')"
golden_usable() {
local ref="$1"
docker run --rm "${ref}:${GOLDEN_TAG}" \
bash -c "
set -euo pipefail
test -f /app/uv.lock
test \"\$(sha256sum /app/uv.lock | awk '{ print \$1 }')\" = '${lockfile_sha}'
/app/.venv/bin/python -c 'import polymorphic'
"
}
ensure_golden() {
local target="$1"
local ref="$2"
local build_args=()
if [ "${target}" = "celery-deps-base" ]; then
build_args+=(--build-arg "GOLDEN_WEB_IMAGE=${WEB_GOLDEN_REF}:${GOLDEN_TAG}")
fi
if docker buildx imagetools inspect "${ref}:${GOLDEN_TAG}" >/dev/null 2>&1 \
&& golden_usable "${ref}"; then
return 0
fi
docker buildx prune --all --force || true
docker builder prune --all --force || true
docker buildx build \
-f ./docker/Dockerfile \
--target "${target}" \
"${build_args[@]}" \
--push \
-t "${ref}:${GOLDEN_TAG}" \
-t "${ref}:latest" \
.
}
ensure_golden "web-deps-base" "${WEB_GOLDEN_REF}"
ensure_golden "celery-deps-base" "${CELERY_GOLDEN_REF}"
sha_short="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)"
image_tag="${GITHUB_REF_NAME}-${sha_short}"
registry_path="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}"
web_repository="${registry_path}/${WEB_IMAGE}"
celery_repository="${registry_path}/${CELERY_IMAGE}"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker buildx build \
-f ./docker/Dockerfile \
--platform linux/amd64 \
--file docker/Dockerfile \
--target runtime-web \
--build-arg INSTALL_DEV=false \
--build-arg GOLDEN_WEB_IMAGE="${WEB_GOLDEN_REF}:${GOLDEN_TAG}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.created=${BUILD_TIME}" \
--label "org.opencontainers.image.created=${build_time}" \
--metadata-file web-metadata.json \
--push \
"${WEB_TAGS[@]}" \
--tag "${web_repository}:${image_tag}" \
.
docker buildx build \
-f ./docker/Dockerfile \
--platform linux/amd64 \
--file docker/Dockerfile \
--target runtime-celery \
--build-arg INSTALL_DEV=false \
--build-arg GOLDEN_CELERY_IMAGE="${CELERY_GOLDEN_REF}:${GOLDEN_TAG}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.created=${BUILD_TIME}" \
--label "org.opencontainers.image.created=${build_time}" \
--metadata-file celery-metadata.json \
--push \
"${CELERY_TAGS[@]}" \
--tag "${celery_repository}:${image_tag}" \
.
web_digest="$(jq -r '."containerimage.digest" // empty' web-metadata.json)"
celery_digest="$(jq -r '."containerimage.digest" // empty' celery-metadata.json)"
printf '%s\n' "${web_digest}" | grep -Eq '^sha256:[0-9a-f]{64}$'
printf '%s\n' "${celery_digest}" | grep -Eq '^sha256:[0-9a-f]{64}$'
{
echo "Registry API: ${REGISTRY_API_URL}"
echo "Golden images:"
echo "- ${WEB_GOLDEN_REF}:${GOLDEN_TAG}"
echo "- ${CELERY_GOLDEN_REF}:${GOLDEN_TAG}"
echo "Pushed images:"
echo "- ${WEB_REF}:dev-${SHA_SHORT}"
echo "- ${WEB_REF}:dev"
echo "- ${CELERY_REF}:dev-${SHA_SHORT}"
echo "- ${CELERY_REF}:dev"
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
notify:
name: Internal Notify
runs-on: ubuntu-latest
timeout-minutes: 1
needs: [quality, build_push]
if: ${{ always() && github.event_name != 'workflow_dispatch' }}
steps:
- name: Send CI status webhook
continue-on-error: true
env:
CI_NOTIFY_WEBHOOK_URL: ${{ secrets.CI_NOTIFY_WEBHOOK_URL }}
CI_NOTIFY_TOKEN: ${{ secrets.CI_NOTIFY_TOKEN }}
QUALITY_RESULT: ${{ needs.quality.result }}
BUILD_PUSH_RESULT: ${{ needs.build_push.result }}
run: |
set -euo pipefail
if [ -z "${CI_NOTIFY_WEBHOOK_URL:-}" ]; then
echo "CI_NOTIFY_WEBHOOK_URL is not set; skip internal notification"
exit 0
fi
STATUS="success"
if [ "${QUALITY_RESULT}" != "success" ]; then
STATUS="${QUALITY_RESULT}"
elif [ "${BUILD_PUSH_RESULT}" = "failure" ] || [ "${BUILD_PUSH_RESULT}" = "cancelled" ]; then
STATUS="${BUILD_PUSH_RESULT}"
fi
export STATUS
PAYLOAD=$(python3 - <<'PY'
import json
import os
payload = {
"project": os.environ.get("GITHUB_REPOSITORY"),
"workflow": os.environ.get("GITHUB_WORKFLOW"),
"status": os.environ.get("STATUS"),
"branch": os.environ.get("GITHUB_HEAD_REF") or os.environ.get("GITHUB_REF_NAME"),
"sha": os.environ.get("GITHUB_SHA"),
"actor": os.environ.get("GITHUB_ACTOR"),
"server_url": os.environ.get("GITHUB_SERVER_URL"),
"run_id": os.environ.get("GITHUB_RUN_ID"),
"results": {
"quality": os.environ.get("QUALITY_RESULT"),
"build_push": os.environ.get("BUILD_PUSH_RESULT"),
},
}
print(json.dumps(payload, ensure_ascii=True, separators=(",", ":")))
PY
)
AUTH_HEADER=()
if [ -n "${CI_NOTIFY_TOKEN:-}" ]; then
AUTH_HEADER=(-H "Authorization: Bearer ${CI_NOTIFY_TOKEN}")
fi
curl -fsS \
--connect-timeout 3 \
--max-time 8 \
--retry 1 \
-H "Content-Type: application/json" \
"${AUTH_HEADER[@]}" \
--data "${PAYLOAD}" \
"${CI_NOTIFY_WEBHOOK_URL}"
echo "web_ref=${web_repository}@${web_digest}"
echo "celery_ref=${celery_repository}@${celery_digest}"
echo "web_tag_ref=${web_repository}:${image_tag}"
echo "celery_tag_ref=${celery_repository}:${image_tag}"
} >> "${GITHUB_OUTPUT}"
deploy_dev:
name: Deploy Dev via Compose
name: Deploy dev
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [build_push]
if: needs.build_push.result == 'success'
timeout-minutes: 15
needs: [build]
if: ${{ github.ref == 'refs/heads/dev' && needs.build.result == 'success' }}
steps:
- name: Checkout code
run: |
set -euo pipefail
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" .
git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}"
- name: Deploy prebuilt images via SSH
- name: Invoke dev release wrapper
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
INTERNAL_DEPLOY_SSH_KEY_B64: ${{ secrets.INTERNAL_DEPLOY_SSH_KEY_B64 }}
INTERNAL_KNOWN_HOSTS_B64: ${{ secrets.INTERNAL_KNOWN_HOSTS_B64 }}
WEB_REF: ${{ needs.build.outputs.web_ref }}
CELERY_REF: ${{ needs.build.outputs.celery_ref }}
run: |
set -euo pipefail
if [ "${GITHUB_REF}" != "refs/heads/dev" ]; then
echo "Skip dev deploy for ${GITHUB_REF}"
exit 0
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${INTERNAL_DEPLOY_SSH_KEY_B64:-}" ] || [ -z "${INTERNAL_KNOWN_HOSTS_B64:-}" ]; then
echo "Internal deploy SSH secrets are required" >&2
exit 1
fi
case "${DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "DEPLOY_USER has unsupported characters" >&2; exit 1;; esac
case "${DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "DEPLOY_HOST has unsupported characters" >&2; exit 1;; esac
printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
case "${HEAD_COMMIT_MESSAGE:-}" in
*"#no_deploy"* | *"#no_image"*)
echo "Skip dev deploy because commit message disables deploy or image build"
ssh_dir="$(mktemp -d)"
trap 'rm -rf "${ssh_dir}"' EXIT
key_path="${ssh_dir}/deploy_key"
known_hosts_path="${ssh_dir}/known_hosts"
printf '%s' "${INTERNAL_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}"
printf '%s' "${INTERNAL_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}"
chmod 600 "${key_path}" "${known_hosts_path}"
ssh-keygen -y -f "${key_path}" >/dev/null
ssh \
-i "${key_path}" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o "UserKnownHostsFile=${known_hosts_path}" \
-o GlobalKnownHostsFile=/dev/null \
-o ConnectTimeout=15 \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
/opt/ecos-dev/release-service \
mostovik-backend \
"${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
"${WEB_REF}" \
"${CELERY_REF}"
deploy_internal_main:
name: Deploy and verify internal main
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [build]
if: ${{ github.ref == 'refs/heads/main' && needs.build.result == 'success' }}
steps:
- name: Refresh clone and invoke internal-main release wrapper
env:
MAIN_DEPLOY_HOST: ${{ secrets.MAIN_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
INTERNAL_DEPLOY_SSH_KEY_B64: ${{ secrets.INTERNAL_DEPLOY_SSH_KEY_B64 }}
INTERNAL_KNOWN_HOSTS_B64: ${{ secrets.INTERNAL_KNOWN_HOSTS_B64 }}
INTERNAL_MAIN_MOSTOVIK_BACKEND_URL: ${{ secrets.INTERNAL_MAIN_MOSTOVIK_BACKEND_URL }}
WEB_REF: ${{ needs.build.outputs.web_ref }}
CELERY_REF: ${{ needs.build.outputs.celery_ref }}
run: |
set -euo pipefail
if [ -z "${MAIN_DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${INTERNAL_DEPLOY_SSH_KEY_B64:-}" ] || [ -z "${INTERNAL_KNOWN_HOSTS_B64:-}" ] || [ -z "${INTERNAL_MAIN_MOSTOVIK_BACKEND_URL:-}" ]; then
echo "Internal-main deploy and smoke secrets are required" >&2
exit 1
fi
case "${DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "DEPLOY_USER has unsupported characters" >&2; exit 1;; esac
case "${MAIN_DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "MAIN_DEPLOY_HOST has unsupported characters" >&2; exit 1;; esac
printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
ssh_dir="$(mktemp -d)"
trap 'rm -rf "${ssh_dir}"' EXIT
key_path="${ssh_dir}/deploy_key"
known_hosts_path="${ssh_dir}/known_hosts"
printf '%s' "${INTERNAL_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}"
printf '%s' "${INTERNAL_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}"
chmod 600 "${key_path}" "${known_hosts_path}"
ssh-keygen -y -f "${key_path}" >/dev/null
ssh \
-i "${key_path}" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o "UserKnownHostsFile=${known_hosts_path}" \
-o GlobalKnownHostsFile=/dev/null \
-o ConnectTimeout=15 \
"${DEPLOY_USER}@${MAIN_DEPLOY_HOST}" \
/opt/ecos-main/release-service \
mostovik-backend \
"${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
"${WEB_REF}" \
"${CELERY_REF}" \
--refresh-data
smoke_url="${INTERNAL_MAIN_MOSTOVIK_BACKEND_URL%/}/health/ready/"
for attempt in $(seq 1 30); do
if curl --fail --silent --output /dev/null --max-time 10 "${smoke_url}"; then
echo "Internal-main backend readiness passed"
exit 0
;;
esac
fi
echo "Internal-main backend readiness attempt ${attempt}/30 failed"
sleep 5
done
echo "Internal-main backend readiness failed" >&2
exit 1
short_sha="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)"
image_tag="dev-${short_sha}"
mkdir -p ~/.ssh
printf '%s' "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/ecos_deploy_key
chmod 0600 ~/.ssh/ecos_deploy_key
ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
tmp_current="$(mktemp)"
ssh -i ~/.ssh/ecos_deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" 'cat /opt/ecos-dev/releases/current.env' > "${tmp_current}"
grep -v '^MOSTOVIK_BACKEND_' "${tmp_current}" > "${tmp_current}.new"
cat >> "${tmp_current}.new" <<EOF
MOSTOVIK_BACKEND_WEB_IMAGE=${REGISTRY_HOST}/avm/mostovik-backend-web:${image_tag}
MOSTOVIK_BACKEND_CELERY_IMAGE=${REGISTRY_HOST}/avm/mostovik-backend-celery:${image_tag}
EOF
scp -i ~/.ssh/ecos_deploy_key "${tmp_current}.new" "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/current.env"
ssh -i ~/.ssh/ecos_deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" 'cat /tmp/current.env > /opt/ecos-dev/releases/current.env && rm -f /tmp/current.env && /opt/ecos-dev/deploy.sh mostovik-backend'
deploy_customer_main:
name: Deploy customer main
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [build, deploy_internal_main]
if: ${{ github.ref == 'refs/heads/main' && needs.build.result == 'success' && needs.deploy_internal_main.result == 'success' }}
steps:
- name: Invoke customer release wrapper with the verified digests
env:
CUSTOMER_DEPLOY_SSH_KEY_B64: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY_B64 }}
CUSTOMER_KNOWN_HOSTS_B64: ${{ secrets.CUSTOMER_KNOWN_HOSTS_B64 }}
WEB_REF: ${{ needs.build.outputs.web_ref }}
CELERY_REF: ${{ needs.build.outputs.celery_ref }}
run: |
set -euo pipefail
for name in CUSTOMER_DEPLOY_HOST CUSTOMER_DEPLOY_USER CUSTOMER_SSH_PROXY_HOST CUSTOMER_DEPLOY_SSH_KEY_B64 CUSTOMER_KNOWN_HOSTS_B64; do
if [ -z "${!name:-}" ]; then
echo "Missing required customer SSH secret: ${name}" >&2
exit 1
fi
done
case "${CUSTOMER_DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "Customer SSH user has unsupported characters" >&2; exit 1;; esac
case "${CUSTOMER_DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "Customer SSH host has unsupported characters" >&2; exit 1;; esac
case "${CUSTOMER_SSH_PROXY_HOST}" in *[!A-Za-z0-9._@:-]*) echo "Customer SSH proxy has unsupported characters" >&2; exit 1;; esac
printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$'
ssh_dir="$(mktemp -d)"
trap 'rm -rf "${ssh_dir}"' EXIT
key_path="${ssh_dir}/deploy_key"
known_hosts_path="${ssh_dir}/known_hosts"
printf '%s' "${CUSTOMER_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}"
printf '%s' "${CUSTOMER_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}"
chmod 600 "${key_path}" "${known_hosts_path}"
ssh-keygen -y -f "${key_path}" >/dev/null
ssh_common=(
-i "${key_path}"
-o BatchMode=yes
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=${known_hosts_path}"
-o GlobalKnownHostsFile=/dev/null
-o ConnectTimeout=15
)
proxy_command="ssh -i ${key_path} -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${known_hosts_path} -o GlobalKnownHostsFile=/dev/null -o ConnectTimeout=15 -W %h:%p ${CUSTOMER_SSH_PROXY_HOST}"
ssh \
"${ssh_common[@]}" \
-o "ProxyCommand=${proxy_command}" \
"${CUSTOMER_DEPLOY_USER}@${CUSTOMER_DEPLOY_HOST}" \
/ecos/release-service \
mostovik-backend \
"${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
"${WEB_REF}" \
"${CELERY_REF}"

View File

@@ -1,192 +0,0 @@
name: Deploy Customer Main
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: mostovik-backend-customer-main-${{ github.ref }}
cancel-in-progress: true
env:
CUSTOMER_REGISTRY_HOST: registry.dev.nii-ecos.ru
CUSTOMER_REGISTRY_NAMESPACE: avm
CUSTOMER_WEB_IMAGE: mostovik-backend-web
CUSTOMER_CELERY_IMAGE: mostovik-backend-celery
CUSTOMER_DEPLOY_USER: ecos
CUSTOMER_DEPLOY_HOST: 10.0.10.174
CUSTOMER_SSH_PROXY_HOST: root@10.10.0.121
CUSTOMER_COMPOSE_FILE: /ecos/docker-compose.yml
CUSTOMER_DEPLOY_SCRIPT: /ecos/pull-and-run.sh
jobs:
deploy:
name: Build, Push, Deploy
runs-on: [backend-docker]
timeout-minutes: 90
steps:
- name: Ensure main branch
run: |
set -euo pipefail
if [ "${GITHUB_REF_NAME}" != "main" ]; then
echo "Customer deploy is allowed only from main; current ref is ${GITHUB_REF_NAME}" >&2
exit 1
fi
- name: Checkout code
run: |
set -euo pipefail
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" .
git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}"
- name: Validate deploy inputs
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
GITEA_TOKEN: ${{ gitea.token }}
CUSTOMER_DEPLOY_SSH_KEY: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY }}
CUSTOMER_DEPLOY_SSH_KEY_B64: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY_B64 }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
registry_user="${REGISTRY_USERNAME:-${REGISTRY_USER:-${GITHUB_ACTOR:-}}}"
registry_password="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-${GITEA_TOKEN:-}}}"
home_dir="${HOME:-/root}"
if [ -z "${registry_user}" ]; then
echo "Missing registry user secret: set REGISTRY_USER or REGISTRY_USERNAME" >&2
exit 1
fi
if [ -z "${registry_password}" ]; then
echo "Missing registry password secret: set REGISTRY_TOKEN or REGISTRY_PASSWORD" >&2
exit 1
fi
if [ -z "${CUSTOMER_DEPLOY_SSH_KEY:-}" ] \
&& [ -z "${CUSTOMER_DEPLOY_SSH_KEY_B64:-}" ] \
&& [ -z "${DEPLOY_SSH_KEY:-}" ] \
&& [ ! -f "${home_dir}/.ssh/ci-key" ] \
&& [ ! -f "/root/.ssh/ci-key" ]; then
echo "Missing customer SSH key: set CUSTOMER_DEPLOY_SSH_KEY_B64, CUSTOMER_DEPLOY_SSH_KEY, DEPLOY_SSH_KEY, or install ~/.ssh/ci-key on the runner" >&2
exit 1
fi
- name: Setup Docker Buildx
run: |
set -euo pipefail
if ! docker buildx inspect customer-builder >/dev/null 2>&1; then
docker buildx create --name customer-builder --driver docker-container --use
else
docker buildx use customer-builder
fi
docker buildx inspect --bootstrap
- name: Build and push customer images
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
GITEA_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
registry_user="${REGISTRY_USERNAME:-${REGISTRY_USER:-${GITHUB_ACTOR:-}}}"
registry_password="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-${GITEA_TOKEN:-}}}"
sha_short="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)"
registry_path="${CUSTOMER_REGISTRY_HOST}/${CUSTOMER_REGISTRY_NAMESPACE}"
web_ref="${registry_path}/${CUSTOMER_WEB_IMAGE}"
celery_ref="${registry_path}/${CUSTOMER_CELERY_IMAGE}"
printf '%s' "${registry_password}" \
| docker login "${CUSTOMER_REGISTRY_HOST}" \
-u "${registry_user}" \
--password-stdin
docker buildx build \
--platform linux/amd64 \
-f ./docker/Dockerfile \
--target runtime-web \
--build-arg INSTALL_DEV=false \
--push \
-t "${web_ref}:latest" \
-t "${web_ref}:${sha_short}" \
.
docker buildx build \
--platform linux/amd64 \
-f ./docker/Dockerfile \
--target runtime-celery \
--build-arg INSTALL_DEV=false \
--push \
-t "${celery_ref}:latest" \
-t "${celery_ref}:${sha_short}" \
.
- name: Deploy customer stack
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
GITEA_TOKEN: ${{ gitea.token }}
CUSTOMER_DEPLOY_SSH_KEY: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY }}
CUSTOMER_DEPLOY_SSH_KEY_B64: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY_B64 }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
registry_user="${REGISTRY_USERNAME:-${REGISTRY_USER:-${GITHUB_ACTOR:-}}}"
registry_password="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-${GITEA_TOKEN:-}}}"
case "${registry_user}" in
*[!A-Za-z0-9._@-]*)
echo "Registry user contains unsupported characters" >&2
exit 1
;;
esac
home_dir="${HOME:-/root}"
mkdir -p "${home_dir}/.ssh"
key_path="${home_dir}/.ssh/customer_deploy_key"
if [ -f "/root/.ssh/ci-key" ]; then
cp "/root/.ssh/ci-key" "${key_path}"
elif [ -f "${home_dir}/.ssh/ci-key" ]; then
cp "${home_dir}/.ssh/ci-key" "${key_path}"
elif [ -n "${CUSTOMER_DEPLOY_SSH_KEY_B64:-}" ]; then
printf '%s' "${CUSTOMER_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}"
elif [ -n "${DEPLOY_SSH_KEY:-}" ]; then
printf '%s' "${DEPLOY_SSH_KEY}" | base64 -d > "${key_path}"
elif [ -n "${CUSTOMER_DEPLOY_SSH_KEY:-}" ]; then
printf '%s\n' "${CUSTOMER_DEPLOY_SSH_KEY}" > "${key_path}"
else
echo "Customer deploy SSH key is unavailable" >&2
exit 1
fi
chmod 600 "${key_path}"
ssh_common=(
-i "${key_path}"
-o BatchMode=yes
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o GlobalKnownHostsFile=/dev/null
-o LogLevel=ERROR
-o ConnectTimeout=15
)
proxy_command="ssh -i ${key_path} -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=15 -W %h:%p ${CUSTOMER_SSH_PROXY_HOST}"
remote_command="set -euo pipefail
flock -w 1800 /tmp/ecos-customer-deploy.lock /bin/sh -c 'cd /ecos && FORCE_PULL=1 COMPOSE_FILE=\"${CUSTOMER_COMPOSE_FILE}\" \"${CUSTOMER_DEPLOY_SCRIPT}\" && docker image prune -f'"
ssh "${ssh_common[@]}" -o "ProxyCommand=${proxy_command}" "${CUSTOMER_DEPLOY_USER}@${CUSTOMER_DEPLOY_HOST}" "true"
printf '%s' "${registry_password}" \
| ssh "${ssh_common[@]}" -o "ProxyCommand=${proxy_command}" "${CUSTOMER_DEPLOY_USER}@${CUSTOMER_DEPLOY_HOST}" \
"docker login '${CUSTOMER_REGISTRY_HOST}' -u '${registry_user}' --password-stdin"
ssh "${ssh_common[@]}" -o "ProxyCommand=${proxy_command}" "${CUSTOMER_DEPLOY_USER}@${CUSTOMER_DEPLOY_HOST}" "${remote_command}"

View File

@@ -1,74 +0,0 @@
name: Manual Dev Actions
on:
workflow_dispatch:
inputs:
manual_action:
description: "Manual action: noop, build_golden_images, cleanup_dev_database, or dokploy_start"
required: true
default: "noop"
dokploy_target:
description: "Dokploy dev target: all, web, worker, or beat"
required: true
default: "all"
cleanup_confirm:
description: "Type CLEAN_DEV_DB to drop and recreate the dev database as UTF8"
required: false
default: ""
concurrency:
group: mostovik-backend-manual-dev-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY_API_URL: "https://registry.dev.nii-ecos.ru/v2/"
REGISTRY_HOST: "registry.dev.nii-ecos.ru"
REGISTRY_NAMESPACE: "${{ github.repository_owner }}"
WEB_IMAGE: "mostovik-backend-web"
CELERY_IMAGE: "mostovik-backend-celery"
CI_GOLDEN_IMAGE: "mostovik-backend-ci-golden"
WEB_GOLDEN_IMAGE: "mostovik-backend-web-golden"
CELERY_GOLDEN_IMAGE: "mostovik-backend-celery-golden"
GOLDEN_TAG: "py311-uv0.7.2"
DOKPLOY_DEV_WEB_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/_EjfuYBpzGJ18uPwBZ3iF"
DOKPLOY_DEV_WORKER_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/hltL7K2HmG1a8EIzr-mVA"
DOKPLOY_DEV_BEAT_WEBHOOK_URL: "https://deploy.dev.nii-ecos.ru/api/deploy/RkdykbqU6faErrZBAN9Rv"
DOKPLOY_API_URL: "https://deploy.dev.nii-ecos.ru/api"
DOKPLOY_DEV_WEB_APPLICATION_ID: "x2l_Twc2z2A4lJhMVqlNg"
DOKPLOY_DEV_WORKER_APPLICATION_ID: "m8ECastEeQKhDZVFonUTS"
DOKPLOY_DEV_BEAT_APPLICATION_ID: "Ut5e5mcMMslxG9Zrpbp0_"
DOKPLOY_DEV_WEB_APP_NAME: "service-backend-4mbxrs"
DOKPLOY_DEV_WORKER_APP_NAME: "service-backend-512y9c"
DOKPLOY_DEV_BEAT_APP_NAME: "service-backend-nvdyoq"
jobs:
manual_dev_action:
name: Run Manual Dev Action
runs-on: ubuntu-latest
timeout-minutes: 60
env:
MANUAL_ACTION: ${{ github.event.inputs.manual_action }}
DOKPLOY_TARGET: ${{ github.event.inputs.dokploy_target }}
CLEANUP_CONFIRM: ${{ github.event.inputs.cleanup_confirm }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }}
DOKPLOY_API_TOKEN_FALLBACK: "cmhRpAPDlWPCbwkCdteTgpHuHzhPHCNtZrUcRddsfiHdijmyXKsIIojiBmcVpfpo"
POSTGRES_HOST: "10.10.0.114"
POSTGRES_PORT: "5432"
POSTGRES_DB: "mostovik"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "postgres"
steps:
- name: Checkout code
run: |
set -euo pipefail
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
git -c core.hooksPath=/dev/null clone --depth=1 --branch="${BRANCH}" "${REPO_URL}" .
git -c core.hooksPath=/dev/null checkout "${GITHUB_SHA}"
- name: Run selected action
run: bash scripts/ci/manual_dev_action.sh