55045184e2
Гейт «версия не должна уменьшаться» отвергал и равную версию безусловно, что делало невозможным повторный прогон на том же теге (Task 10 Step 4 плана). Теперь сравнение version+sourceSha читается из plugin/server.lock.json ветки dist: равная версия проходит дальше только если sourceSha из dist совпадает с текущим github.sha (ретрай уже опубликованного релиза — ниже по пайплайну он и так идемпотентен: upsert релиза/ассета, повторная сверка sha256, force-push dist тем же содержимым). Другой sourceSha при той же версии — попытка подменить уже опубликованный релиз другим кодом — отвергается с явным сообщением, какой коммит уже занимает эту версию. Меньшая версия отвергается как и раньше.
179 lines
11 KiB
YAML
179 lines
11 KiB
YAML
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}"
|
||
CURRENT_SHA="${{ github.sha }}"
|
||
|
||
# Сверяем по plugin/server.lock.json (не plugin.json): в нём вместе с версией
|
||
# лежит и sourceSha, а он нужен ниже, чтобы отличить ретрай того же релиза от
|
||
# попытки переопубликовать ту же версию другим коммитом.
|
||
#
|
||
# `git show origin/dist:...` может не найти ни ветку, ни файл (первый релиз —
|
||
# dist ещё не существует). Не полагаемся на `cmd || echo fallback` вокруг пайпа
|
||
# с jq: jq на пустом stdin ничего не печатает, но выходит с кодом 0, поэтому
|
||
# такой fallback никогда бы не сработал — здесь fallback навешан на сам git show,
|
||
# jq вызывается только когда файл реально прочитан. Без dist сравнивать sourceSha
|
||
# не с чем — OLD_SOURCE_SHA остаётся пустым, и равенство версий ниже недостижимо
|
||
# (OLD_VERSION="0.0.0" всегда меньше валидного тега vX.Y.Z, кроме вырожденного
|
||
# v0.0.0, который проверка формата тега не отсекает, но это не наш случай).
|
||
git fetch origin dist 2>/dev/null || true
|
||
OLD_VERSION="0.0.0"
|
||
OLD_SOURCE_SHA=""
|
||
if git show origin/dist:plugin/server.lock.json > /tmp/dist-lock.json 2>/dev/null; then
|
||
OLD_VERSION=$(jq -r '.version // "0.0.0"' /tmp/dist-lock.json)
|
||
OLD_SOURCE_SHA=$(jq -r '.sourceSha // ""' /tmp/dist-lock.json)
|
||
fi
|
||
|
||
if [ "$OLD_VERSION" = "$NEW" ]; then
|
||
# Равная версия допускается ровно в одном случае: тег указывает на тот же
|
||
# коммит, из которого эта версия уже была опубликована в dist — это ретрай
|
||
# (сбойнул шаг после публикации ассета, тег пересоздали на том же SHA и т.п.),
|
||
# и весь пайплайн ниже идемпотентен сам по себе (upsert релиза/ассета, повторная
|
||
# сверка sha256, force-push dist тем же содержимым). Если sourceSha другой — под
|
||
# тем же номером версии протаскивают другой код, это не ретрай, а подмена уже
|
||
# опубликованного релиза; такое отвергаем, а не публикуем молча поверх старого.
|
||
if [ -n "$OLD_SOURCE_SHA" ] && [ "$OLD_SOURCE_SHA" = "$CURRENT_SHA" ]; then
|
||
echo "ok: повтор релиза $NEW с того же коммита $CURRENT_SHA"
|
||
else
|
||
echo "версия $NEW уже опубликована из коммита ${OLD_SOURCE_SHA:-<неизвестно>} — поднимите версию"
|
||
exit 1
|
||
fi
|
||
else
|
||
SMALLEST=$(printf '%s\n%s\n' "$OLD_VERSION" "$NEW" | sort -V | head -1)
|
||
if [ "$SMALLEST" != "$OLD_VERSION" ]; then
|
||
echo "версия $NEW не больше опубликованной $OLD_VERSION"
|
||
exit 1
|
||
fi
|
||
echo "ok: $NEW > $OLD_VERSION"
|
||
fi
|
||
|
||
- 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
|