From 49d549463a44eb192eeccefe4e9b8541f4dd559d Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Fri, 4 Sep 2026 19:27:17 +0300 Subject: [PATCH] =?UTF-8?q?CI/CD=20=D0=B2=20Gitea=20Actions=20=D0=B8=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow CI гоняет gofmt, vet, тесты и сборку на каждый push в main; workflow Release по тегу v* собирает бинарники под linux/amd64 и arm64, считает контрольные суммы и публикует релиз через API Gitea. Оба workflow забирают код через git, а не actions/checkout: это JS-действие, которому нужен Node внутри контейнера, а в образе golang его нет. Заодно сборка перестала зависеть от доступности github.com, что для self-hosted инстанса существенно. Скрипт deploy/get.sh ставит последний релиз одной командой: определяет архитектуру, забирает файлы релиза, сверяет sha256 (бинарник ставится с правами root) и передаёт управление install.sh. Обновление — та же команда. Версия сборки попадает в бинарник через ldflags, доступна как `pzmanager version` и видна в панели на вкладке «Настройки»: без неё нельзя понять, какой релиз стоит на сервере. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/ci.yml | 50 ++++++++++++++ .gitea/workflows/release.yml | 116 ++++++++++++++++++++++++++++++++ Makefile | 4 +- README.md | 79 ++++++++++++++++------ cmd/pzmanager/main.go | 14 +++- deploy/get.sh | 107 +++++++++++++++++++++++++++++ internal/api/handlers_config.go | 2 + internal/api/server.go | 4 ++ web/static/app.js | 3 +- 9 files changed, 356 insertions(+), 23 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 deploy/get.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a294733 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + # Собираем в официальном образе Go: версия компилятора фиксирована и + # совпадает с релизной сборкой. + container: golang:1.24 + steps: + # Код забираем сами, а не через actions/checkout: это JS-действие, ему + # нужен Node внутри контейнера, которого в образе golang нет. Заодно + # сборка перестаёт зависеть от доступности github.com. + - name: Забрать код + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + git config --global --add safe.directory "$PWD" + git init -q . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git -c http.extraheader="Authorization: token ${TOKEN}" \ + fetch --depth 1 origin "${GITHUB_SHA}" + git checkout -q FETCH_HEAD + + - name: Проверить форматирование + run: | + unformatted="$(gofmt -l cmd internal web)" + if [ -n "$unformatted" ]; then + echo "Не отформатировано (запустите gofmt -w):" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Тесты + run: go test ./... + + - name: Проверить сборку под Linux + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/pzmanager + + - name: Проверить синтаксис скриптов установки + run: bash -n deploy/install.sh && bash -n deploy/get.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..09f0458 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + container: golang:1.24 + steps: + # Код забираем сами: actions/checkout — JS-действие, которому нужен Node + # внутри контейнера, а в образе golang его нет. История нужна целиком, + # чтобы собрать список изменений с прошлого тега. + - name: Забрать код + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + git config --global --add safe.directory "$PWD" + git init -q . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git -c http.extraheader="Authorization: token ${TOKEN}" \ + fetch --tags origin + git checkout -q "${GITHUB_SHA}" + + - name: Собрать бинарники + run: | + set -eu + VERSION="${GITHUB_REF_NAME}" + mkdir -p dist + + for arch in amd64 arm64; do + echo "==> pzmanager-linux-${arch}" + CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o "dist/pzmanager-linux-${arch}" ./cmd/pzmanager + done + + # Скрипты установки кладём в релиз: тогда get.sh ставит версию + # целиком из одного источника и не зависит от состояния ветки. + cp deploy/install.sh deploy/pzmanager.service deploy/get.sh dist/ + + cd dist + sha256sum pzmanager-linux-* install.sh pzmanager.service get.sh > checksums.txt + cat checksums.txt + + - name: Опубликовать релиз + env: + # Токен репозитория Actions выдаёт сам; RELEASE_TOKEN нужен, только + # если в настройках инстанса права такого токена урезаны. + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + apt-get update -qq && apt-get install -y -qq jq + + TOKEN="${RELEASE_TOKEN:-$DEFAULT_TOKEN}" + if [ -z "$TOKEN" ]; then + echo "Нет токена для публикации: заведите секрет RELEASE_TOKEN" >&2 + exit 1 + fi + + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + TAG="${GITHUB_REF_NAME}" + AUTH="Authorization: token ${TOKEN}" + + # Список изменений с предыдущего тега; для первого релиза — вся + # история. + PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + if [ -n "$PREV" ]; then + CHANGES="$(git log --no-merges --pretty=format:'- %s' "${PREV}..${TAG}")" + else + CHANGES="$(git log --no-merges --pretty=format:'- %s')" + fi + + NOTES="$(printf '## Установка\n\n```bash\ncurl -fsSL %s/%s/raw/branch/main/deploy/get.sh | sudo bash\n```\n\nОбновление — та же команда: конфиг, миры и учётные записи не трогаются.\n\n## Изменения\n\n%s\n' \ + "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${CHANGES}")" + + PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$NOTES" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}')" + + # Тег могли перевыпустить — тогда работаем с существующим релизом. + ID="$(curl -sS -H "$AUTH" "${API}/releases/tags/${TAG}" | jq -r '.id // empty')" + + if [ -n "$ID" ]; then + echo "==> Обновляю существующий релиз ${TAG} (id ${ID})" + curl -sS -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ + -d "$PAYLOAD" "${API}/releases/${ID}" > /dev/null + # Старые файлы удаляем: иначе загрузка упрётся в совпадающие имена. + curl -sS -H "$AUTH" "${API}/releases/${ID}/assets" | jq -r '.[].id' \ + | while read -r asset; do + [ -n "$asset" ] || continue + curl -sS -X DELETE -H "$AUTH" "${API}/releases/${ID}/assets/${asset}" > /dev/null + done + else + echo "==> Создаю релиз ${TAG}" + ID="$(curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d "$PAYLOAD" "${API}/releases" | jq -r '.id')" + fi + + if [ -z "$ID" ] || [ "$ID" = "null" ]; then + echo "Не удалось получить id релиза — проверьте права токена" >&2 + exit 1 + fi + + for file in dist/*; do + name="$(basename "$file")" + echo "==> Загружаю ${name}" + curl -sS -f -X POST -H "$AUTH" \ + -F "attachment=@${file}" \ + "${API}/releases/${ID}/assets?name=${name}" > /dev/null + done + + echo "Готово: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG}" diff --git a/Makefile b/Makefile index 558cddd..24c8985 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ BINARY := pzmanager -LDFLAGS := -s -w +# Версия попадает в бинарник и видна в панели и в `pzmanager version`. +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) .PHONY: build build-linux test vet fmt run clean diff --git a/README.md b/README.md index 8c159f4..20bfc22 100644 --- a/README.md +++ b/README.md @@ -47,21 +47,30 @@ systemd ──> pzmanager ──> start-server.sh ──> java (GameServer) ## Быстрый старт -Собрать бинарник (нужен Go 1.24+; собирать можно на любой ОС, включая Windows): +На чистом Ubuntu/Debian всё ставится одной командой — она возьмёт последний +релиз, проверит контрольные суммы и настроит systemd: ```bash -make build-linux +curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash ``` -Скопировать `pzmanager` и папку `deploy/` на сервер и запустить установщик: +По умолчанию панель слушает `127.0.0.1:8080` — только с самой машины. Чтобы +она была доступна в локальной сети, укажите адрес сервера: ```bash -sudo ./deploy/install.sh +curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh \ + | sudo PZ_LISTEN=10.10.1.142:8080 bash ``` -Установщик заведёт системного пользователя `pzserver`, поставит зависимости -(SteamCMD, JRE, SDL2), положит бинарник в `/usr/local/bin`, создаст структуру -в `/opt/pzmanager` и включит systemd-юнит: +Переменные: `PZ_LISTEN` (адрес панели), `PZ_HOME` (каталог, по умолчанию +`/opt/pzmanager`), `PZ_USER` (системный пользователь), `PZ_VERSION` +(конкретный тег вместо последнего). + +Скрипт запускается от root, так что перед первым запуском его разумно +прочитать: [deploy/get.sh](deploy/get.sh). + +Установщик поставит зависимости (SteamCMD, JRE, SDL2), заведёт пользователя +`pzserver` без shell, положит бинарник в `/usr/local/bin` и создаст структуру: ``` /opt/pzmanager/ @@ -72,17 +81,8 @@ sudo ./deploy/install.sh └── backups/ архивы ``` -Куда ставить и на каком адресе слушать, задаётся переменными окружения: - -```bash -# панель доступна в локальной сети по адресу машины -sudo PZ_LISTEN=10.10.1.142:8080 ./deploy/install.sh - -# другой каталог и пользователь -sudo PZ_HOME=/srv/pz PZ_USER=zomboid ./deploy/install.sh -``` - -По умолчанию панель слушает `127.0.0.1:8080` — только с самой машины. +**Обновление** — та же команда: конфиг, миры и учётные записи не трогаются, +меняется только бинарник. Дальше: @@ -113,8 +113,20 @@ sudo ufw allow 16261/udp # игровой порт sudo ufw allow 16262/udp # второй игровой порт ``` -Обновление ставится поверх: соберите новый бинарник и повторите -`sudo ./deploy/install.sh` — конфиг, миры и учётки останутся на месте. +### Установка из исходников + +Если релизам предпочитаете свою сборку (нужен Go 1.24+, собирать можно на +любой ОС, включая Windows): + +```bash +make build-linux +``` + +Скопируйте `pzmanager` и папку `deploy/` на сервер и запустите установщик: + +```bash +sudo PZ_LISTEN=10.10.1.142:8080 ./deploy/install.sh +``` ## Моды @@ -290,6 +302,33 @@ backup: Тем не менее доступ к панели равносилен доступу к игровому серверу, так что пароль стоит выбрать не короче того, что вы поставили бы на SSH. +## Сборка и релизы + +Репозиторий собирается Gitea Actions ([`.gitea/workflows`](.gitea/workflows)): + +- **CI** — на каждый push и pull request в `main`: `gofmt`, `go vet`, тесты, + проверка сборки под Linux и синтаксиса скриптов установки. +- **Release** — на тег `v*`: собирает бинарники под `linux/amd64` и + `linux/arm64`, считает контрольные суммы и публикует релиз с ними и + скриптами установки. + +Выпуск новой версии: + +```bash +git tag -a v1.2.3 -m "Описание релиза" && git push origin v1.2.3 +``` + +Версия попадает в бинарник через `-ldflags -X main.version=` и видна в +`pzmanager version` и в панели на вкладке «Настройки». + +Workflow не зависят от github.com и от Node внутри контейнера: код забирается +через `git`, а релиз публикуется прямо в API Gitea. Токен Actions выдаёт сам; +если в настройках инстанса его права урезаны, заведите секрет `RELEASE_TOKEN` +с доступом на запись в репозиторий. + +Для работы Actions нужен зарегистрированный runner с меткой `ubuntu-latest`, +умеющий запускать контейнеры (`docker`). + ## Разработка ```bash diff --git a/cmd/pzmanager/main.go b/cmd/pzmanager/main.go index c8e9d85..68a5f50 100644 --- a/cmd/pzmanager/main.go +++ b/cmd/pzmanager/main.go @@ -29,6 +29,10 @@ import ( "github.com/mrleo1nid/pzmanager/web" ) +// version подставляется при сборке: -ldflags "-X main.version=v1.2.3". +// В сборке из рабочей копии остаётся "dev". +var version = "dev" + const usage = `pzmanager — панель управления сервером Project Zomboid. Использование: @@ -36,6 +40,7 @@ const usage = `pzmanager — панель управления сервером pzmanager useradd <логин> создать пользователя панели pzmanager passwd <логин> сменить пароль pzmanager users показать список пользователей + pzmanager version показать версию Флаги: ` @@ -56,6 +61,12 @@ func main() { if len(args) > 0 && !strings.HasPrefix(args[0], "-") { command, args = args[0], args[1:] } + // Версию печатаем до загрузки конфига: она должна работать всегда, + // в том числе на сломанной конфигурации. + if command == "version" || command == "--version" { + fmt.Println("pzmanager", version) + return + } if err := fs.Parse(args); err != nil { os.Exit(2) } @@ -123,6 +134,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error { } apiServer := api.New(api.Options{ + Version: version, Config: cfg, Users: users, Supervisor: supervisor, @@ -155,7 +167,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error { if err != nil { return fmt.Errorf("не удалось занять адрес %s: %w", cfg.Listen, err) } - fmt.Printf("pzmanager слушает http://%s\n", cfg.Listen) + fmt.Printf("pzmanager %s слушает http://%s\n", version, cfg.Listen) if cfg.AutoStart { if err := supervisor.Start(); err != nil { diff --git a/deploy/get.sh b/deploy/get.sh new file mode 100644 index 0000000..f7f0b66 --- /dev/null +++ b/deploy/get.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Установка или обновление pzmanager до последнего релиза одной командой: +# +# curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash +# +# Панель в локальной сети (подставьте адрес своего сервера): +# +# curl -fsSL .../get.sh | sudo PZ_LISTEN=10.10.1.142:8080 bash +# +# Переменные окружения: +# PZ_VERSION какой тег ставить (по умолчанию последний релиз) +# PZ_HOME куда ставить (по умолчанию /opt/pzmanager) +# PZ_USER системный пользователь (по умолчанию pzserver) +# PZ_LISTEN адрес веб-панели (по умолчанию 127.0.0.1:8080) +# PZ_REPO адрес репозитория (по умолчанию этот) +set -euo pipefail + +PZ_REPO="${PZ_REPO:-https://gitea.hsrv.site/mrleo1nid/pzmanager}" +PZ_VERSION="${PZ_VERSION:-latest}" + +API="${PZ_REPO%/}" +API="${API%.git}" +# Из https://host/owner/repo делаем https://host/api/v1/repos/owner/repo +HOST="$(printf '%s' "$API" | cut -d/ -f1-3)" +PATH_PART="${API#"$HOST"/}" +API="${HOST}/api/v1/repos/${PATH_PART}" + +die() { echo "Ошибка: $*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "запустите с правами root (sudo)" + +for tool in curl sha256sum; do + command -v "$tool" >/dev/null 2>&1 || die "не найдена утилита $tool" +done + +case "$(uname -m)" in + x86_64 | amd64) ARCH=amd64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) die "архитектура $(uname -m) не поддерживается: есть сборки только под amd64 и arm64" ;; +esac + +if [[ "$PZ_VERSION" == "latest" ]]; then + RELEASE_URL="${API}/releases/latest" +else + RELEASE_URL="${API}/releases/tags/${PZ_VERSION}" +fi + +echo "==> Спрашиваю у Gitea последний релиз" +RELEASE_JSON="$(curl -fsSL "$RELEASE_URL")" \ + || die "не удалось получить релиз с ${RELEASE_URL}" + +TAG="$(printf '%s' "$RELEASE_JSON" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)" +[[ -n "$TAG" ]] || die "в ответе нет тега — возможно, релизов ещё нет" + +# Ссылки на файлы релиза достаём без jq: он есть далеко не на всякой машине. +# Имя файла сравниваем целиком, а не шаблоном: точка в "install.sh" в регулярке +# совпала бы с любым символом. +asset_url() { + printf '%s' "$RELEASE_JSON" \ + | grep -o '"browser_download_url" *: *"[^"]*"' \ + | cut -d'"' -f4 \ + | while read -r url; do + if [[ "${url##*/}" == "$1" ]]; then + printf '%s' "$url" + return + fi + done +} + +BIN_NAME="pzmanager-linux-${ARCH}" +BIN_URL="$(asset_url "$BIN_NAME")" +[[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}" + +echo "==> Ставлю ${TAG} (${ARCH})" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cd "$TMP" + +download() { + local name="$1" url required="${2:-required}" + url="$(asset_url "$name")" + if [[ -z "$url" ]]; then + [[ "$required" == "optional" ]] && return 1 + die "в релизе ${TAG} нет файла ${name}" + fi + curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}" +} + +download "$BIN_NAME" +download install.sh +download pzmanager.service + +# Контрольные суммы обязательны: бинарник ставится с правами root. +download checksums.txt +echo "==> Проверяю контрольные суммы" +grep -E " (${BIN_NAME}|install\.sh|pzmanager\.service)\$" checksums.txt > expected.txt \ + || die "в checksums.txt нет нужных строк" +sha256sum -c expected.txt || die "контрольные суммы не сошлись — файлы повреждены" + +mkdir -p deploy +mv install.sh pzmanager.service deploy/ +mv "$BIN_NAME" pzmanager +chmod +x pzmanager deploy/install.sh + +echo "==> Запускаю установщик" +BINARY="$TMP/pzmanager" bash deploy/install.sh diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index 78a3f7a..bf5e0b5 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -127,6 +127,7 @@ type managerConfigDTO struct { BackupKeep int `json:"backup_keep"` BackupStopServer bool `json:"backup_stop_server"` ConfigPath string `json:"config_path"` + Version string `json:"version"` } func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) { @@ -145,6 +146,7 @@ func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) BackupKeep: c.Backup.Keep, BackupStopServer: c.Backup.StopServer, ConfigPath: c.Path(), + Version: s.version, }) } diff --git a/internal/api/server.go b/internal/api/server.go index 911d7f2..ed6293a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -26,6 +26,7 @@ const csrfHeader = "X-PZM-Request" // Server связывает HTTP-слой со всеми подсистемами менеджера. type Server struct { + version string cfg *config.Config users *auth.Store sup *pzserver.Supervisor @@ -43,6 +44,8 @@ type Server struct { // Options — то, что нужно собрать серверу API. type Options struct { + // Version — версия сборки, панель показывает её в настройках. + Version string Config *config.Config Users *auth.Store Supervisor *pzserver.Supervisor @@ -57,6 +60,7 @@ type Options struct { // New собирает HTTP-обработчик панели. func New(o Options) *Server { return &Server{ + version: o.Version, cfg: o.Config, users: o.Users, sup: o.Supervisor, diff --git a/web/static/app.js b/web/static/app.js index e684530..0283b2c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1198,7 +1198,8 @@ async function loadSettings() { const pathNote = document.createElement("p"); pathNote.className = "muted small"; - pathNote.textContent = `Файл конфигурации: ${config.config_path}`; + pathNote.textContent = + `PZ Manager ${config.version || "?"} · файл конфигурации: ${config.config_path}`; form.appendChild(pathNote); await loadUsers();