ci: сборочный и релизный workflow для Gitea Actions (Task 9-10)

ci.yml: сборка + unit-тесты на Linux-раннере (EnableWindowsTargeting), плюс
проверка согласованности plugin/.claude-plugin/plugin.json и
plugin/server.lock.json (версии совпадают; для опубликованного релиза —
sha256 непустой и 64 hex).

release.yml: на тег vX.Y.Z — публикация self-contained win-x64,
идемпотентный upsert релиза/ассета через Gitea API, сверка SHA256
опубликованного ассета перед обновлением, и force-push ветки dist поверх
коммита с тегом (server.lock.json + plugin.json). Отличия от черновика в
плане: устойчивый разбор "версия не должна уменьшаться" (не полагается на
особенность jq на пустом stdin), явные HTTP-коды вместо curl -f для
различения "релиза нет" от прочих ошибок API, persist-credentials: false
на checkout, чтобы не конфликтовать с собственным токеном на пуше.

Не прогонялось на раннере — раннер ещё не готов (отдельная работа).
This commit is contained in:
2026-07-31 02:53:26 +03:00
parent 58eba795cf
commit e2fe197fbb
2 changed files with 211 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
# Плагин: server.lock.json — то, что скачивает лаунчер; plugin.json — то, что видит
# Claude Code как версию установки. Обе должны совпадать между собой, иначе релизный
# workflow отдал бы плагин с пином, который сам себе противоречит.
- name: инструменты для проверки манифеста плагина
run: apt-get update -qq && apt-get install -y -qq jq
- name: согласованность plugin.json и server.lock.json
run: |
MANIFEST_VERSION=$(jq -r '.version' plugin/.claude-plugin/plugin.json)
LOCK_VERSION=$(jq -r '.version' plugin/server.lock.json)
LOCK_SHA256=$(jq -r '.sha256' plugin/server.lock.json)
if [ "$MANIFEST_VERSION" != "$LOCK_VERSION" ]; then
echo "версия plugin.json ($MANIFEST_VERSION) не совпадает с server.lock.json ($LOCK_VERSION)"
exit 1
fi
# 0.0.0 — состояние «релиз ещё не публиковался»: url/sha256 намеренно пустые
# (см. tests/Kompas.Mcp.Tests/PluginManifestTests.cs, Lock_WhenReleased_HasUrlAndSha256).
if [ "$LOCK_VERSION" != "0.0.0" ]; then
if [ -z "$LOCK_SHA256" ] || [ "$LOCK_SHA256" = "null" ]; then
echo "server.lock.json: версия $LOCK_VERSION опубликована, но sha256 пуст"
exit 1
fi
if ! echo "$LOCK_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then
echo "server.lock.json: sha256 '$LOCK_SHA256' не похож на 64-символьный hex"
exit 1
fi
fi
echo "ok: plugin.json/server.lock.json согласованы (version=$LOCK_VERSION)"
# EnableWindowsTargeting: проект таргетит net8.0-windows, сборка идёт на Linux.
- name: build
run: dotnet build -c Release -p:EnableWindowsTargeting=true
# Интеграционные тесты в CI не выполняются: им нужен запущенный КОМПАС с GUI и лицензией.
- name: unit tests
run: dotnet test --filter "Category=Unit" -c Release -p:EnableWindowsTargeting=true --no-build
+154
View File
@@ -0,0 +1,154 @@
name: release
on:
push:
tags: ['v*']
# Релизы сериализуются: параллельные теги не должны гонять ветку dist одновременно
# (обе гоняющиеся сборки делают `git push --force dist`, и без сериализации может
# выиграть та, что стартовала позже, но закончила раньше — с более старой версией).
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
env:
OWNER: mikhail
REPO: kompas3d-mcp
GITEA: https://git.shahovalov.ru
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Пушим в dist собственным GITEA_TOKEN явным URL (см. последний шаг) —
# заголовок авторизации, который checkout оставляет в git-конфиге для
# выданного джобу токена, тут не нужен и может конфликтовать с ним
# (два способа аутентификации на один и тот же хост в одном запросе).
persist-credentials: false
- name: инструменты
run: apt-get update -qq && apt-get install -y -qq jq zip curl git
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: проверить формат тега
run: |
echo "${{ github.ref_name }}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "тег должен быть вида vX.Y.Z, получено: ${{ github.ref_name }}"; exit 1; }
- name: версия не должна уменьшаться
run: |
NEW="${{ github.ref_name }}"; NEW="${NEW#v}"
# `git show origin/dist:...` может не найти ни ветку, ни файл (первый релиз —
# dist ещё не существует). Не полагаемся на `cmd || echo fallback` вокруг пайпа
# с jq: jq на пустом stdin ничего не печатает, но выходит с кодом 0, поэтому
# такой fallback никогда бы не сработал — здесь fallback навешан на сам git show,
# jq вызывается только когда файл реально прочитан.
git fetch origin dist 2>/dev/null || true
OLD="0.0.0"
if git show origin/dist:plugin/.claude-plugin/plugin.json > /tmp/dist-plugin.json 2>/dev/null; then
OLD=$(jq -r '.version // "0.0.0"' /tmp/dist-plugin.json)
fi
SMALLEST=$(printf '%s\n%s\n' "$OLD" "$NEW" | sort -V | head -1)
# Отвергаем и меньшую, и равную версию: SMALLEST должен быть строго OLD
# (т.е. OLD < NEW), а не совпадать с NEW.
if [ "$SMALLEST" != "$OLD" ] || [ "$OLD" = "$NEW" ]; then
echo "версия $NEW не больше опубликованной $OLD"
exit 1
fi
echo "ok: $NEW > $OLD"
- name: publish
run: |
VERSION="${{ github.ref_name }}"; VERSION="${VERSION#v}"
dotnet publish src/Kompas.Mcp.Host/Kompas.Mcp.Host.csproj \
-c Release -r win-x64 --self-contained true \
-p:EnableWindowsTargeting=true -p:Version="$VERSION" \
-o out/publish
test -f out/publish/kompas-mcp.exe || { echo "нет kompas-mcp.exe в out/publish"; exit 1; }
# Плоская структура: exe и зависимости прямо в корне архива, без каталога out/publish/.
(cd out/publish && zip -qr "../kompas-mcp-$VERSION-win-x64.zip" .)
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "ASSET=out/kompas-mcp-$VERSION-win-x64.zip" >> "$GITHUB_ENV"
echo "SHA256=$(sha256sum "out/kompas-mcp-$VERSION-win-x64.zip" | cut -d' ' -f1)" >> "$GITHUB_ENV"
- name: upsert релиза и ассета
run: |
API="$GITEA/api/v1/repos/$OWNER/$REPO"
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
TAG="${{ github.ref_name }}"
NAME="kompas-mcp-$VERSION-win-x64.zip"
# Найти существующий релиз по тегу; создать, если его ещё нет. Различаем "нет
# релиза" (404) от прочих ошибок API явно по HTTP-коду — не глотаем и то, и
# другое одним `|| echo ""`, как в черновике: сетевая/авторизационная ошибка
# не должна молча трактоваться как "релиза нет, создаём новый".
HTTP=$(curl -s -o /tmp/release.json -w '%{http_code}' -H "$AUTH" "$API/releases/tags/$TAG")
if [ "$HTTP" = "200" ]; then
ID=$(jq -r '.id' /tmp/release.json)
elif [ "$HTTP" = "404" ]; then
HTTP=$(curl -s -o /tmp/release.json -w '%{http_code}' -X POST -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" "$API/releases")
if [ "$HTTP" != "201" ]; then
echo "не удалось создать релиз $TAG (HTTP $HTTP)"; cat /tmp/release.json; exit 1
fi
ID=$(jq -r '.id' /tmp/release.json)
else
echo "не удалось получить релиз по тегу $TAG (HTTP $HTTP)"; cat /tmp/release.json; exit 1
fi
# Повторный прогон на том же теге не должен падать на "ассет уже существует":
# старый ассет с тем же именем удаляется перед загрузкой нового.
HTTP=$(curl -s -o /tmp/assets.json -w '%{http_code}' -H "$AUTH" "$API/releases/$ID/assets")
if [ "$HTTP" != "200" ]; then
echo "не удалось получить список ассетов релиза $ID (HTTP $HTTP)"; cat /tmp/assets.json; exit 1
fi
EXISTING=$(jq -r --arg name "$NAME" '.[] | select(.name==$name) | .id' /tmp/assets.json)
if [ -n "$EXISTING" ]; then
curl -sf -X DELETE -H "$AUTH" "$API/releases/$ID/assets/$EXISTING"
fi
HTTP=$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST -H "$AUTH" \
-F "attachment=@$ASSET" "$API/releases/$ID/assets?name=$NAME")
if [ "$HTTP" != "201" ]; then
echo "не удалось загрузить ассет $NAME (HTTP $HTTP)"; cat /tmp/asset.json; exit 1
fi
echo "URL=$GITEA/$OWNER/$REPO/releases/download/$TAG/$NAME" >> "$GITHUB_ENV"
- name: сверить опубликованный ассет
run: |
curl -sfL "$URL" -o /tmp/published.zip
ACTUAL=$(sha256sum /tmp/published.zip | cut -d' ' -f1)
[ "$ACTUAL" = "$SHA256" ] || { echo "SHA256 опубликованного ассета не совпал: ожидался $SHA256, получен $ACTUAL"; exit 1; }
- name: обновить ветку dist
run: |
git config user.name "gitea-actions"
git config user.email "actions@git.shahovalov.ru"
git remote set-url origin "$GITEA/$OWNER/$REPO.git"
# dist — не история, а единственный коммит поверх коммита с тегом (см. архитектуру
# в docs/superpowers/plans/2026-07-31-kompas-plugin-distribution.md): каждый новый
# релиз пересобирает dist заново поверх своего тега, поэтому push принудительный.
# concurrency-группа выше гарантирует, что это не гонка между двумя релизами разом.
git checkout -B dist "${{ github.sha }}"
jq --arg v "$VERSION" --arg s "$SHA256" --arg u "$URL" --arg src "${{ github.sha }}" \
'.version=$v | .sha256=$s | .url=$u | .sourceSha=$src' \
plugin/server.lock.json > /tmp/lock.json && mv /tmp/lock.json plugin/server.lock.json
jq --arg v "$VERSION" '.version=$v' \
plugin/.claude-plugin/plugin.json > /tmp/manifest.json && mv /tmp/manifest.json plugin/.claude-plugin/plugin.json
git add plugin/server.lock.json plugin/.claude-plugin/plugin.json
git commit -m "release: kompas $VERSION"
git push --force "https://x-access-token:${{ secrets.GITEA_TOKEN }}@git.shahovalov.ru/$OWNER/$REPO.git" dist