CI/CD в Gitea Actions и установка одной командой
CI / test (push) Successful in 56s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:27:30 +03:00
co-authored by Claude Opus 5
parent 29782dcce9
commit 49d549463a
9 changed files with 356 additions and 23 deletions
+50
View File
@@ -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
+116
View File
@@ -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}"
+3 -1
View File
@@ -1,5 +1,7 @@
BINARY := pzmanager 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 .PHONY: build build-linux test vet fmt run clean
+59 -20
View File
@@ -47,21 +47,30 @@ systemd ──> pzmanager ──> start-server.sh ──> java (GameServer)
## Быстрый старт ## Быстрый старт
Собрать бинарник (нужен Go 1.24+; собирать можно на любой ОС, включая Windows): На чистом Ubuntu/Debian всё ставится одной командой — она возьмёт последний
релиз, проверит контрольные суммы и настроит systemd:
```bash ```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 ```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`, поставит зависимости Переменные: `PZ_LISTEN` (адрес панели), `PZ_HOME` (каталог, по умолчанию
(SteamCMD, JRE, SDL2), положит бинарник в `/usr/local/bin`, создаст структуру `/opt/pzmanager`), `PZ_USER` (системный пользователь), `PZ_VERSION`
в `/opt/pzmanager` и включит systemd-юнит: (конкретный тег вместо последнего).
Скрипт запускается от root, так что перед первым запуском его разумно
прочитать: [deploy/get.sh](deploy/get.sh).
Установщик поставит зависимости (SteamCMD, JRE, SDL2), заведёт пользователя
`pzserver` без shell, положит бинарник в `/usr/local/bin` и создаст структуру:
``` ```
/opt/pzmanager/ /opt/pzmanager/
@@ -72,17 +81,8 @@ sudo ./deploy/install.sh
└── backups/ архивы └── 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 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. пароль стоит выбрать не короче того, что вы поставили бы на 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 ```bash
+13 -1
View File
@@ -29,6 +29,10 @@ import (
"github.com/mrleo1nid/pzmanager/web" "github.com/mrleo1nid/pzmanager/web"
) )
// version подставляется при сборке: -ldflags "-X main.version=v1.2.3".
// В сборке из рабочей копии остаётся "dev".
var version = "dev"
const usage = `pzmanager — панель управления сервером Project Zomboid. const usage = `pzmanager — панель управления сервером Project Zomboid.
Использование: Использование:
@@ -36,6 +40,7 @@ const usage = `pzmanager — панель управления сервером
pzmanager useradd <логин> создать пользователя панели pzmanager useradd <логин> создать пользователя панели
pzmanager passwd <логин> сменить пароль pzmanager passwd <логин> сменить пароль
pzmanager users показать список пользователей pzmanager users показать список пользователей
pzmanager version показать версию
Флаги: Флаги:
` `
@@ -56,6 +61,12 @@ func main() {
if len(args) > 0 && !strings.HasPrefix(args[0], "-") { if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
command, args = args[0], args[1:] command, args = args[0], args[1:]
} }
// Версию печатаем до загрузки конфига: она должна работать всегда,
// в том числе на сломанной конфигурации.
if command == "version" || command == "--version" {
fmt.Println("pzmanager", version)
return
}
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
os.Exit(2) os.Exit(2)
} }
@@ -123,6 +134,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
} }
apiServer := api.New(api.Options{ apiServer := api.New(api.Options{
Version: version,
Config: cfg, Config: cfg,
Users: users, Users: users,
Supervisor: supervisor, Supervisor: supervisor,
@@ -155,7 +167,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
if err != nil { if err != nil {
return fmt.Errorf("не удалось занять адрес %s: %w", cfg.Listen, err) 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 cfg.AutoStart {
if err := supervisor.Start(); err != nil { if err := supervisor.Start(); err != nil {
+107
View File
@@ -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
+2
View File
@@ -127,6 +127,7 @@ type managerConfigDTO struct {
BackupKeep int `json:"backup_keep"` BackupKeep int `json:"backup_keep"`
BackupStopServer bool `json:"backup_stop_server"` BackupStopServer bool `json:"backup_stop_server"`
ConfigPath string `json:"config_path"` ConfigPath string `json:"config_path"`
Version string `json:"version"`
} }
func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) { 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, BackupKeep: c.Backup.Keep,
BackupStopServer: c.Backup.StopServer, BackupStopServer: c.Backup.StopServer,
ConfigPath: c.Path(), ConfigPath: c.Path(),
Version: s.version,
}) })
} }
+4
View File
@@ -26,6 +26,7 @@ const csrfHeader = "X-PZM-Request"
// Server связывает HTTP-слой со всеми подсистемами менеджера. // Server связывает HTTP-слой со всеми подсистемами менеджера.
type Server struct { type Server struct {
version string
cfg *config.Config cfg *config.Config
users *auth.Store users *auth.Store
sup *pzserver.Supervisor sup *pzserver.Supervisor
@@ -43,6 +44,8 @@ type Server struct {
// Options — то, что нужно собрать серверу API. // Options — то, что нужно собрать серверу API.
type Options struct { type Options struct {
// Version — версия сборки, панель показывает её в настройках.
Version string
Config *config.Config Config *config.Config
Users *auth.Store Users *auth.Store
Supervisor *pzserver.Supervisor Supervisor *pzserver.Supervisor
@@ -57,6 +60,7 @@ type Options struct {
// New собирает HTTP-обработчик панели. // New собирает HTTP-обработчик панели.
func New(o Options) *Server { func New(o Options) *Server {
return &Server{ return &Server{
version: o.Version,
cfg: o.Config, cfg: o.Config,
users: o.Users, users: o.Users,
sup: o.Supervisor, sup: o.Supervisor,
+2 -1
View File
@@ -1198,7 +1198,8 @@ async function loadSettings() {
const pathNote = document.createElement("p"); const pathNote = document.createElement("p");
pathNote.className = "muted small"; pathNote.className = "muted small";
pathNote.textContent = `Файл конфигурации: ${config.config_path}`; pathNote.textContent =
`PZ Manager ${config.version || "?"} · файл конфигурации: ${config.config_path}`;
form.appendChild(pathNote); form.appendChild(pathNote);
await loadUsers(); await loadUsers();