Добавлено автоверсионирование SemVer и версия v0.1.1 в футере.

This commit is contained in:
Sergey Antropoff
2026-07-27 22:49:36 +03:00
parent 0a0ae1f1e6
commit be337c0d40
14 changed files with 327 additions and 51 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ RUN apt-get update \
WORKDIR /app
COPY pyproject.toml README.md ./
COPY pyproject.toml README.md VERSION ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
Vendored
+80 -26
View File
@@ -1,21 +1,15 @@
// Wrapped — CI: multi-arch (amd64+arm64) build & push to Harbor + Docker Hub
// Wrapped — CI: auto SemVer bump + multi-arch push (Harbor + Docker Hub) + deploy
//
// Триггер: push в main (webhook Gitea → Jenkins Multibranch / Pipeline from SCM).
// После успешного Build & push → trigger job DEPLOY_JOB (Jenkinsfile.deploy).
// Триггер: push в main (webhook Gitea → Jenkins Multibranch).
// Коммиты с [skip ci] в сообщении — без повторного bump (анти-цикл после version commit).
//
// Credentials в Jenkins (Manage Credentials → System → Global):
// harbor-devops-tools-push-pull-access — Username with password (Harbor devops-tools)
// docker-hub — Username with password (Docker Hub)
// Credentials:
// harbor-devops-tools-push-pull-access — Harbor devops-tools
// docker-hub — Docker Hub
// gitea-ssh (или GIT_SSH_CREDENTIALS_ID) — SSH для push bump-коммита
//
// Agent: cloud kubernetes, pod template default (labels docker / dind)
//
// Образы:
// hub.antropoff.ru/devops-tools/wrapped:<sha|0.1.0|latest>
// inecs/wrapped:<sha|0.1.0|latest>
//
// Связка Build → Deploy:
// Multibranch Deploy job, Script Path = Jenkinsfile.deploy
// путь по умолчанию: devops-tools/wrapped/wrapped-deploy/main (поменяй DEPLOY_JOB при другом имени)
// Версия: файл VERSION (+ pyproject / Helm). Теги образа: :<semver>, :<sha>, :latest
// Deploy: devops-tools/wrapped/wrapped-deploy/main
pipeline {
agent { label 'docker' }
@@ -31,11 +25,11 @@ pipeline {
HARBOR_REGISTRY = 'hub.antropoff.ru'
HARBOR_IMAGE = 'hub.antropoff.ru/devops-tools/wrapped'
DOCKERHUB_IMAGE = 'inecs/wrapped'
IMAGE_VERSION = '0.1.0'
RELEASE_TAG = "${env.GIT_COMMIT?.take(7) ?: 'dev'}"
BUILDX_BUILDER = "jenkins-wrapped-${env.BUILD_NUMBER}"
// Multibranch fullName (не display name): devops-tools » wrapped » wrapped-deploy » main
DEPLOY_JOB = 'devops-tools/wrapped/wrapped-deploy/main'
// SSH credential для git push bump (тот же ключ, что SCM Gitea)
GIT_SSH_CREDENTIALS_ID = 'gitea-ssh'
TZ = 'Europe/Moscow'
}
@@ -46,13 +40,42 @@ pipeline {
}
}
stage('Build & push') {
stage('Version') {
when {
anyOf {
branch 'main'
branch 'master'
}
}
steps {
container('docker') {
script {
sh 'apk add --no-cache python3 git openssh-client >/dev/null'
def lastMsg = sh(script: 'git log -1 --pretty=%B', returnStdout: true).trim()
if (lastMsg.contains('[skip ci]')) {
env.IMAGE_VERSION = sh(script: 'cat VERSION', returnStdout: true).trim()
env.SKIP_CI = '1'
echo "Skip CI for version-bump commit ([skip ci]); VERSION=${env.IMAGE_VERSION}"
} else {
env.IMAGE_VERSION = sh(
script: 'PYTHONPATH=. python3 scripts/bump_version.py patch',
returnStdout: true
).trim()
env.SKIP_CI = '0'
echo "Bumped VERSION → ${env.IMAGE_VERSION}"
}
}
}
}
}
stage('Build & push') {
when {
allOf {
anyOf { branch 'main'; branch 'master' }
expression { return env.SKIP_CI != '1' }
}
}
steps {
withCredentials([
usernamePassword(
@@ -70,6 +93,9 @@ pipeline {
sh '''
set -eux
test -n "${IMAGE_VERSION}"
echo "Building tags: ${IMAGE_VERSION}, ${RELEASE_TAG}, latest"
echo "$HARBOR_PASS" | docker login "$HARBOR_REGISTRY" -u "$HARBOR_USER" --password-stdin
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USER" --password-stdin
@@ -77,7 +103,6 @@ pipeline {
docker buildx create --name "$BUILDX_BUILDER" --driver docker-container --use
docker buildx inspect --bootstrap >/dev/null
# Per-arch images → both registries (Harbor is happier than a single multi-arch --push)
docker buildx build \
--platform linux/amd64 \
--provenance=false --sbom=false --push \
@@ -92,7 +117,6 @@ pipeline {
-t "${DOCKERHUB_IMAGE}:${RELEASE_TAG}-arm64" \
-f Dockerfile .
# Multi-arch manifests: :sha, :0.1.0, :latest on each registry
for IMAGE in "$HARBOR_IMAGE" "$DOCKERHUB_IMAGE"; do
docker buildx imagetools create \
-t "${IMAGE}:${RELEASE_TAG}" \
@@ -103,9 +127,9 @@ pipeline {
done
echo "--- Harbor ---"
docker buildx imagetools inspect "${HARBOR_IMAGE}:${RELEASE_TAG}" | sed -n '1,40p'
docker buildx imagetools inspect "${HARBOR_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p'
echo "--- Docker Hub ---"
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${RELEASE_TAG}" | sed -n '1,40p'
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p'
docker buildx rm "$BUILDX_BUILDER" || true
'''
@@ -114,11 +138,40 @@ pipeline {
}
}
stage('Commit version') {
when {
allOf {
anyOf { branch 'main'; branch 'master' }
expression { return env.SKIP_CI != '1' }
}
}
steps {
container('docker') {
sshagent(credentials: [env.GIT_SSH_CREDENTIALS_ID]) {
sh '''
set -eux
git config user.email "jenkins@antropoff.ru"
git config user.name "Jenkins"
git add VERSION pyproject.toml helm/wrapped/Chart.yaml helm/wrapped/values.yaml
if git diff --cached --quiet; then
echo "No version files to commit"
exit 0
fi
git commit -m "Bump version to ${IMAGE_VERSION} [skip ci]."
# Multibranch detached HEAD → push to branch name
BRANCH="${BRANCH_NAME:-main}"
git push origin "HEAD:refs/heads/${BRANCH}"
'''
}
}
}
}
stage('Trigger deploy') {
when {
anyOf {
branch 'main'
branch 'master'
allOf {
anyOf { branch 'main'; branch 'master' }
expression { return env.SKIP_CI != '1' }
}
}
steps {
@@ -141,12 +194,13 @@ pipeline {
post {
success {
echo "✓ Version: ${IMAGE_VERSION}"
echo "✓ Harbor: ${HARBOR_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Docker Hub: ${DOCKERHUB_IMAGE}:{${RELEASE_TAG},${IMAGE_VERSION},latest}"
echo "✓ Deploy: ${DEPLOY_JOB} (${DOCKERHUB_IMAGE}:${IMAGE_VERSION})"
}
failure {
echo "✗ Сборка, push или trigger deploy не удались — см. лог стадий"
echo "✗ Version / build / push / deploy — см. лог стадий"
}
always {
script {
+9 -3
View File
@@ -34,8 +34,8 @@ pipeline {
)
string(
name: 'IMAGE_TAG',
defaultValue: '0.1.0',
description: 'Тег образа (0.1.0 / latest / short-sha после CI build)'
defaultValue: '',
description: 'Тег образа (SemVer из CI). Пусто = cat VERSION из репозитория'
)
choice(
name: 'IMAGE_PULL_POLICY',
@@ -112,9 +112,15 @@ pipeline {
exit 1
fi
# IMAGE_TAG пустой → из VERSION (автоверсионирование)
if [ -z "${IMAGE_TAG}" ]; then
IMAGE_TAG="$(tr -d '[:space:]' < VERSION)"
export IMAGE_TAG
fi
test -n "${IMAGE_TAG}"
echo "Upgrading ${HELM_RELEASE}: ${IMAGE_REPOSITORY}:${IMAGE_TAG} (${IMAGE_PULL_POLICY})"
# Как addon: helm upgrade --install … --atomic --wait
# Как addon: helm upgrade … --atomic --wait
# --reuse-values сохраняет Ingress/секреты/DB/S3 с кластера
helm upgrade "$HELM_RELEASE" "$HELM_CHART" \
--namespace "$HELM_NAMESPACE" \
+29 -8
View File
@@ -1,8 +1,9 @@
.PHONY: help env build up down restart logs shell migrate revision release buildx-setup helm-package helm-lint clean ps push
.PHONY: help env build up down restart logs shell migrate revision release buildx-setup helm-package helm-lint clean ps push bump-patch bump-minor version-commit
COMPOSE ?= docker compose
IMAGE_NAME ?= wrapped
IMAGE_TAG ?= 0.1.0
# SemVer from VERSION (source of truth); override: make release IMAGE_TAG=1.2.3
IMAGE_TAG ?= $(shell cat VERSION 2>/dev/null || echo 0.1.0)
# Docker Hub namespace → image: $(RELEASE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
# Example: inecs/wrapped:0.1.0
RELEASE_REGISTRY ?= inecs
@@ -27,17 +28,21 @@ help:
@echo " make shell Shell into app container"
@echo " make migrate Run alembic upgrade head"
@echo " make revision m=\"msg\" Create alembic revision"
@echo " make bump-patch VERSION +0.0.1 (pyproject + Helm)"
@echo " make bump-minor VERSION +0.1.0"
@echo " make release Multi-arch build & push $(FULL_IMAGE)"
@echo " platforms: $(RELEASE_PLATFORMS)"
@echo " make push git add ., commit (prompt), push origin"
@echo " make push bump-patch + git add/commit (prompt) + push"
@echo " make helm-lint Lint Helm chart"
@echo " make helm-package Package Helm chart"
@echo " make clean Remove containers, volumes, local image"
@echo ""
@echo "Version: $$(cat VERSION 2>/dev/null || echo 0.1.0)"
@echo "Release examples:"
@echo " make release IMAGE_TAG=0.1.0"
@echo " make release IMAGE_TAG=0.1.0 PUSH=0"
@echo " make release IMAGE_TAG=0.1.0 RELEASE_PLATFORMS=linux/arm64"
@echo " make release # tag from VERSION"
@echo " make release IMAGE_TAG=1.2.3"
@echo " make release PUSH=0"
@echo " make release RELEASE_PLATFORMS=linux/arm64"
env:
@test -f .env || cp .env.example .env
@@ -131,8 +136,24 @@ clean:
-docker buildx rm $(BUILDX_BUILDER) 2>/dev/null || true
rm -rf dist
# Same flow as proxmox_api_simulator: stage all, multiline commit via Ctrl-D, push origin.
push:
# Same flow as proxmox_api_simulator / infra: bump SemVer, stage, multiline commit, push.
bump-patch:
@PYTHONPATH=. python3 scripts/bump_version.py patch
@echo "VERSION → $$(cat VERSION)"
bump-minor:
@PYTHONPATH=. python3 scripts/bump_version.py minor
@echo "VERSION → $$(cat VERSION)"
version-commit:
@git add VERSION pyproject.toml helm/wrapped/Chart.yaml helm/wrapped/values.yaml
@if git diff --cached --quiet; then \
echo "Version unchanged — no version commit"; \
else \
git commit -m "Bump version to $$(cat VERSION)."; \
fi
push: bump-patch
@set -e; \
git add .; \
echo "=== staged ==="; \
+18 -5
View File
@@ -27,7 +27,7 @@
| **Расшифровка** | Прокрутка превью текста по вертикали и горизонтали; кнопка **Копировать** рядом со скачиванием |
| **Пароль** | Argon2-проверка *до* выдачи ciphertext; лимит попыток; пустой пароль попытку не тратит; структурированные ошибки в UI |
| **Админка** | Стартовая **Статистика** (MinIO, wraps, audit); очистка audit с подтверждением; пагинация журнала |
| **Релиз** | `make release` / Jenkins CI+CD: multi-arch → Harbor и Docker Hub; Helm upgrade в k3s (`Jenkinsfile.deploy`, как addon-wrapped) |
| **Релиз** | Авто-SemVer (`VERSION`); Jenkins CI+CD: multi-arch → Harbor/Hub; Helm upgrade в k3s |
Подробности — в разделах ниже (безопасность, админка, сборка образа).
@@ -432,7 +432,20 @@ docker buildx build \
| Harbor | `hub.antropoff.ru/devops-tools/wrapped` |
| Docker Hub | `inecs/wrapped` |
Теги на каждый реестр: `:<short-sha>`, `:0.1.0`, `:latest` (плюс служебные `:<sha>-amd64` / `:<sha>-arm64`).
Теги на каждый реестр: `:<semver>` (из `VERSION`, авто +0.0.1 на каждый CI build), `:<short-sha>`, `:latest`.
### Версионирование
Источник правды — файл [`VERSION`](VERSION) (SemVer). Синхронизируется в `pyproject.toml` и Helm (`Chart.yaml`, `values.yaml` image.tag).
| Где | Поведение |
|-----|-----------|
| UI | справа в футере: `vX.Y.Z` |
| `make bump-patch` / `bump-minor` | ручной bump |
| `make push` | автоматически `bump-patch`, затем commit/push |
| Jenkins (`Jenkinsfile`) | на `main` автоматически `bump-patch`, образ с новым тегом, commit `Bump version to X.Y.Z [skip ci].`, затем Deploy |
Коммиты с `[skip ci]` не бампят версию снова (анти-цикл). Credential для push bump: `GIT_SSH_CREDENTIALS_ID` (по умолчанию `gitea-ssh`) — тот же SSH-ключ, что для SCM.
Credentials (как в актуальном `Jenkinsfile`):
@@ -471,14 +484,14 @@ kubectl -n wrapped rollout status deployment/wrapped
| Параметр job | Умолч. | Смысл |
|--------------|--------|--------|
| `IMAGE_REPOSITORY` | `inecs/wrapped` | как `wrapped_image_repository` |
| `IMAGE_TAG` | `0.1.0` | как `wrapped_image_tag` (`latest` / short-sha после build) |
| `IMAGE_TAG` | из `VERSION` / CI | SemVer образа (`Always` подтянет новый) |
| `IMAGE_PULL_POLICY` | `Always` | как в addon |
Credential: `k3s-kubeconfig` (Secret file) — тот же, что у devops.org.ru.
Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после успешного **Build & push** стадия **Trigger deploy** вызывает Multibranch job `Devops Tools/Wrapped Deploy/main` с параметрами `inecs/wrapped` / `0.1.0` / `Always` (`wait: true`). Если имя folder/job другое — поменяй `DEPLOY_JOB` в `Jenkinsfile`.
Рекомендуемая связка job’ов уже в [`Jenkinsfile`](Jenkinsfile): после **Version** + **Build & push** стадия **Trigger deploy** вызывает `devops-tools/wrapped/wrapped-deploy/main` с актуальным SemVer (`wait: true`). Credential SSH для version-commit: `gitea-ssh` (или поменяй `GIT_SSH_CREDENTIALS_ID`).
Ручной запуск Deploy по-прежнему: **Build with Parameters**.
Ручной запуск Deploy: **Build with Parameters** (пустой `IMAGE_TAG``cat VERSION`).
### Git (`make push`)
+1
View File
@@ -0,0 +1 @@
0.1.1
+8 -2
View File
@@ -14,6 +14,7 @@ from app.config import get_settings
from app.db import SessionLocal
from app.services.settings_service import get_or_create_settings
from app.services.storage import storage
from app.version import get_app_version, get_app_version_label
OPENAPI_TAGS = [
{
@@ -73,9 +74,10 @@ def custom_openapi(app: FastAPI):
def create_app() -> FastAPI:
settings = get_settings()
docs_on = settings.is_docs_enabled
app_version = get_app_version()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
version=app_version,
description=(
"Wrapped — zero-knowledge one-time encrypted drop.\n\n"
"Public wrap APIs are anonymous. Admin APIs require HTTP Basic."
@@ -97,10 +99,14 @@ def create_app() -> FastAPI:
app.include_router(admin.api_router)
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = app_version
templates.env.globals["app_version_label"] = get_app_version_label()
admin.templates.env.globals["app_version"] = app_version
admin.templates.env.globals["app_version_label"] = get_app_version_label()
@app.get("/health", tags=["System"], summary="Health check", include_in_schema=docs_on)
async def health():
return {"status": "ok", "service": settings.app_name}
return {"status": "ok", "service": settings.app_name, "version": app_version}
@app.get("/", include_in_schema=False)
async def index(request: Request):
+18
View File
@@ -886,6 +886,24 @@ html[data-theme="light"] .footer-pillars li {
margin: 0.85rem 0 0;
font-size: 0.8rem;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.footer-copy-left {
min-width: 0;
}
.footer-copy-version {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--muted);
opacity: 0.9;
white-space: nowrap;
}
.footer-copy a {
color: var(--accent-2);
+3
View File
@@ -66,8 +66,11 @@
</li>
</ul>
<p class="footer-copy">
<span class="footer-copy-left">
© <span data-i18n="footer.copy.author">Сергей Антропов</span>
· <a href="https://devops.org.ru" target="_blank" rel="noopener noreferrer">devops.org.ru</a>
</span>
<span class="footer-copy-version" title="Version">{{ app_version_label | default('v0.1.0') }}</span>
</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
_VERSION_FILE = Path(__file__).resolve().parents[1] / "VERSION"
_PROJECT_ROOT = _VERSION_FILE.parent
_PYPROJECT_FILE = _PROJECT_ROOT / "pyproject.toml"
_CHART_FILE = _PROJECT_ROOT / "helm" / "wrapped" / "Chart.yaml"
_VALUES_FILE = _PROJECT_ROOT / "helm" / "wrapped" / "values.yaml"
_DEFAULT_VERSION = "0.1.0"
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def _read_version_file(path: Path) -> str | None:
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
return None
if _VERSION_RE.match(text):
return text
return None
@lru_cache
def get_app_version() -> str:
for path in (
_VERSION_FILE,
Path("/app/VERSION"),
Path.cwd() / "VERSION",
):
found = _read_version_file(path)
if found:
return found
try:
from importlib.metadata import version
meta = version("wrapped")
if _VERSION_RE.match(meta):
return meta
except Exception:
pass
return _DEFAULT_VERSION
def get_app_version_label() -> str:
return f"v{get_app_version()}"
def parse_version(value: str) -> tuple[int, int, int]:
match = _VERSION_RE.match(value.strip())
if not match:
raise ValueError(f"Invalid semantic version: {value!r}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def format_version(major: int, minor: int, patch: int) -> str:
return f"{major}.{minor}.{patch}"
def clear_version_cache() -> None:
get_app_version.cache_clear()
def write_project_version(version: str) -> None:
parse_version(version)
_VERSION_FILE.write_text(f"{version}\n", encoding="utf-8")
pyproject = _PYPROJECT_FILE.read_text(encoding="utf-8")
pyproject, count = re.subn(
r'^version = ".*"$',
f'version = "{version}"',
pyproject,
count=1,
flags=re.MULTILINE,
)
if count != 1:
raise RuntimeError("Failed to update pyproject.toml version")
_PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
chart = _CHART_FILE.read_text(encoding="utf-8")
chart, chart_count = re.subn(
r"^version: .*$",
f"version: {version}",
chart,
count=1,
flags=re.MULTILINE,
)
chart, app_count = re.subn(
r'^appVersion: ".*"$',
f'appVersion: "{version}"',
chart,
count=1,
flags=re.MULTILINE,
)
if chart_count != 1 or app_count != 1:
raise RuntimeError("Failed to update Chart.yaml version")
_CHART_FILE.write_text(chart, encoding="utf-8")
values = _VALUES_FILE.read_text(encoding="utf-8")
values, values_count = re.subn(
r'^(\s*tag:\s*)".*"$',
rf'\1"{version}"',
values,
count=1,
flags=re.MULTILINE,
)
if values_count != 1:
raise RuntimeError("Failed to update helm values.yaml image.tag")
_VALUES_FILE.write_text(values, encoding="utf-8")
clear_version_cache()
def bump_patch_version() -> str:
major, minor, patch = parse_version(get_app_version())
version = format_version(major, minor, patch + 1)
write_project_version(version)
return version
def bump_minor_version() -> str:
major, minor, patch = parse_version(get_app_version())
version = format_version(major, minor + 1, 0)
write_project_version(version)
return version
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: wrapped
description: Zero-knowledge one-time encrypted drop (Wrapped)
type: application
version: 0.1.0
appVersion: "0.1.0"
version: 0.1.1
appVersion: "0.1.1"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image:
repository: inecs/wrapped
tag: "0.1.0"
tag: "0.1.1"
pullPolicy: IfNotPresent
service:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wrapped"
version = "0.1.0"
version = "0.1.1"
description = "Zero-knowledge one-time encrypted drop service"
readme = "README.md"
requires-python = ">=3.12"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""CLI: bump project SemVer (VERSION + pyproject + Helm chart/values)."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.version import bump_minor_version, bump_patch_version # noqa: E402
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
if len(args) != 1 or args[0] not in {"patch", "minor"}:
print("Usage: bump_version.py patch|minor", file=sys.stderr)
return 1
version = bump_patch_version() if args[0] == "patch" else bump_minor_version()
print(version)
return 0
if __name__ == "__main__":
raise SystemExit(main())