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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user