name: "Релиз: архив исходников" on: push: tags: - "v*" workflow_dispatch: inputs: tag: description: "Тег релиза (например: v2.2.0)" required: true permissions: contents: write concurrency: group: release-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: true env: DIST_DIR: dist jobs: release: name: "Упаковка и публикация релиза" runs-on: native steps: - name: "Получение исходного кода" shell: bash env: GIT_TOKEN: ${{ secrets.GIT_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail TOKEN="${GIT_TOKEN:-$GITEA_TOKEN}" SERVER="${{ github.server_url }}" REPO="${{ github.repository }}" HOST="${SERVER#https://}" HOST="${HOST#http://}" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then TAG="${{ github.event.inputs.tag }}" else TAG="${GITHUB_REF_NAME}" fi if [ -z "${TAG}" ]; then echo "ОШИБКА: не удалось определить тег релиза." exit 1 fi echo ">>> Клонирование ${REPO} (тег: ${TAG})" if [ -n "${TOKEN}" ]; then if [ "${HOST}" = "github.com" ]; then CLONE_URL="https://x-access-token:${TOKEN}@${HOST}/${REPO}.git" else CLONE_URL="https://${TOKEN}@${HOST}/${REPO}.git" fi else CLONE_URL="${SERVER}/${REPO}.git" fi # Check if tag exists on the remote TAG_EXISTS=false if git ls-remote --exit-code --tags "${CLONE_URL}" "refs/tags/${TAG}" >/dev/null 2>&1; then TAG_EXISTS=true fi if [ "${TAG_EXISTS}" = "true" ]; then echo ">>> Тег ${TAG} найден, клонирование..." git clone --branch "${TAG}" --depth 200 "${CLONE_URL}" . 2>&1 | grep -F -v "${TOKEN:-}" || true else DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" if [ -z "${DEFAULT_BRANCH}" ] || [ "${DEFAULT_BRANCH}" = "null" ]; then DEFAULT_BRANCH="master" fi echo ">>> Тег ${TAG} не найден, клонирование ветки ${DEFAULT_BRANCH}..." git clone --branch "${DEFAULT_BRANCH}" --depth 200 "${CLONE_URL}" . 2>&1 | grep -F -v "${TOKEN:-}" || true fi # Fetch full tag history for proper prev_tag detection git fetch --tags --force 2>/dev/null || true git fetch --deepen=500 2>/dev/null || true # If tag doesn't exist locally, create and push it if [ "${TAG_EXISTS}" = "false" ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo ">>> Создание тега ${TAG} на текущем коммите ($(git rev-parse --short HEAD))..." git tag "${TAG}" git push origin "${TAG}" 2>&1 | grep -F -v "${TOKEN:-}" || true echo ">>> Тег ${TAG} создан и отправлен" fi echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV" echo ">>> Исходный код получен: $(git log --oneline -1 2>/dev/null || echo 'N/A')" - name: "Подготовка метаданных релиза" id: meta shell: bash run: | set -euo pipefail TAG="${RELEASE_TAG}" VERSION="${TAG#v}" PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true) echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "prev_tag=${PREV_TAG}" >> "$GITHUB_OUTPUT" echo "══════════════════════════════════════" echo " Тег: ${TAG}" echo " Версия: ${VERSION}" echo " Пред. тег: ${PREV_TAG:-<первый релиз>}" echo " Коммит: ${GITHUB_SHA:0:8}" echo "══════════════════════════════════════" - name: "Создание архивов исходного кода" shell: bash run: | set -euo pipefail TAG="${{ steps.meta.outputs.tag }}" REPO_NAME="${GITHUB_REPOSITORY##*/}" ARCHIVE_BASE="${REPO_NAME}-${TAG}" rm -rf .release-work "${DIST_DIR}" mkdir -p .release-work "${DIST_DIR}" if command -v rsync >/dev/null 2>&1; then rsync -a \ --exclude '.git/' \ --exclude '.src-repo/' \ --exclude '.wiki-repo/' \ --exclude '.release-work/' \ --exclude "${DIST_DIR}/" \ ./ ".release-work/${ARCHIVE_BASE}/" else cp -a . ".release-work/${ARCHIVE_BASE}" rm -rf \ ".release-work/${ARCHIVE_BASE}/.git" \ ".release-work/${ARCHIVE_BASE}/.src-repo" \ ".release-work/${ARCHIVE_BASE}/.wiki-repo" \ ".release-work/${ARCHIVE_BASE}/.release-work" \ ".release-work/${ARCHIVE_BASE}/${DIST_DIR}" fi tar -czf "${DIST_DIR}/${ARCHIVE_BASE}.tar.gz" -C .release-work "${ARCHIVE_BASE}" if command -v zip >/dev/null 2>&1; then ( cd .release-work zip -qr "../${DIST_DIR}/${ARCHIVE_BASE}.zip" "${ARCHIVE_BASE}" ) else ARCHIVE_BASE="${ARCHIVE_BASE}" DIST_DIR="${DIST_DIR}" python3 - <<'PY' import os import zipfile base = os.environ["ARCHIVE_BASE"] src = os.path.join(".release-work", base) dst = os.path.join(os.environ["DIST_DIR"], f"{base}.zip") with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: for root, _, files in os.walk(src): for file_name in files: full = os.path.join(root, file_name) arc = os.path.relpath(full, ".release-work") zf.write(full, arc) PY fi sha256sum "${DIST_DIR}"/* > "${DIST_DIR}/checksums-sha256.txt" echo "ARCHIVE_BASE=${ARCHIVE_BASE}" >> "$GITHUB_ENV" echo ">>> Артефакты релиза:" ls -lh "${DIST_DIR}" - name: "Генерация описания релиза" shell: bash run: | set -euo pipefail TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" PREV_TAG="${{ steps.meta.outputs.prev_tag }}" DATE_UTC=$(date -u '+%Y-%m-%d %H:%M UTC') CHANGELOG_FILE="" if [ -f "wiki/Changelog.md" ]; then CHANGELOG_FILE="wiki/Changelog.md" elif [ -f "docs/ru/CHANGELOG.md" ]; then CHANGELOG_FILE="docs/ru/CHANGELOG.md" elif [ -f "docs/en/CHANGELOG_EN.md" ]; then CHANGELOG_FILE="docs/en/CHANGELOG_EN.md" fi CHANGELOG_SECTION="" if [ -n "${CHANGELOG_FILE}" ]; then # Поддерживаем оба формата заголовков: "## [VERSION]" и "### [VERSION] - DATE" CHANGELOG_SECTION=$(awk -v ver="${VERSION}" ' $0 ~ "^#{2,3} \\[" ver "\\]" { in_section=1; next } /^#{2,3} \[/ && in_section { exit } in_section { print } ' "${CHANGELOG_FILE}") fi COMMITS="" if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then if [ -n "${PREV_TAG}" ]; then COMMITS=$(git log --pretty=format:'- %s (%h)' "${PREV_TAG}..${TAG}" --no-merges | head -50) else COMMITS=$(git log --pretty=format:'- %s (%h)' --no-merges | head -50) fi else echo ">>> Предупреждение: .git недоступен, список коммитов будет пропущен" fi { echo "## Release ${TAG}" echo "" echo "Релиз скриптового проекта без компиляции бинарников." echo "" echo "### Что входит в релиз" echo "- Исходный код в форматах: ${ARCHIVE_BASE}.tar.gz, ${ARCHIVE_BASE}.zip" echo "- Контрольные суммы: checksums-sha256.txt" echo "" if [ -n "${CHANGELOG_SECTION}" ]; then echo "### Что сделано (из CHANGELOG)" echo "${CHANGELOG_SECTION}" echo "" fi echo "### Коммиты релиза" if [ -n "${COMMITS}" ]; then echo "${COMMITS}" else echo "- Недоступно (runner без .git)" fi echo "" echo "---" echo "" echo "- Тег: ${TAG}" echo "- Коммит: ${GITHUB_SHA}" echo "- Дата (UTC): ${DATE_UTC}" } > /tmp/release_body.md echo ">>> Сформировано описание релиза: /tmp/release_body.md" - name: "Создание релиза и загрузка архивов" shell: bash env: GIT_TOKEN: ${{ secrets.GIT_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail TOKEN="${GIT_TOKEN:-$GITEA_TOKEN}" if [ -z "${TOKEN}" ]; then echo "ОШИБКА: не задан секрет GIT_TOKEN (или GITEA_TOKEN)." exit 1 fi SERVER="${{ github.server_url }}" REPO="${{ github.repository }}" HOST="${SERVER#https://}" HOST="${HOST#http://}" TAG="${{ steps.meta.outputs.tag }}" # Set up API URLs and auth header early (needed for tag check below) if [ "${HOST}" = "github.com" ]; then CREATE_URL="https://api.github.com/repos/${REPO}/releases" AUTH_HEADER="Authorization: Bearer ${TOKEN}" EXTRA_HEADERS=(-H "Accept: application/vnd.github+json") else CREATE_URL="${SERVER}/api/v1/repos/${REPO}/releases" AUTH_HEADER="Authorization: token ${TOKEN}" EXTRA_HEADERS=() fi TARGET_COMMITISH="${RELEASE_REF:-${{ github.event.repository.default_branch }}}" if [ -z "${TARGET_COMMITISH}" ] || [ "${TARGET_COMMITISH}" = "null" ]; then TARGET_COMMITISH="master" fi if [ -n "${GITHUB_SHA:-}" ]; then TARGET_COMMITISH="${GITHUB_SHA}" fi if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then TARGET_COMMITISH="$(git rev-list -n 1 "${TAG}" 2>/dev/null || echo "${TARGET_COMMITISH}")" fi # Ensure the tag exists on the remote (Gitea requires it before creating a release) if [ "${HOST}" != "github.com" ]; then TAG_HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ -H "${AUTH_HEADER}" \ "${SERVER}/api/v1/repos/${REPO}/tags/${TAG}") if [ "${TAG_HTTP_CODE}" != "200" ]; then echo ">>> Тег ${TAG} не найден на сервере (HTTP ${TAG_HTTP_CODE}), создание через API..." TAG_CREATE_RESP=$(curl -sS -X POST \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json" \ "${SERVER}/api/v1/repos/${REPO}/tags" \ -d "{\"tag_name\":\"${TAG}\",\"target\":\"${TARGET_COMMITISH}\"}") echo ">>> Ответ создания тега: ${TAG_CREATE_RESP}" else echo ">>> Тег ${TAG} существует на сервере" fi fi BODY_JSON=$(python3 - <<'PY' import json with open('/tmp/release_body.md', 'r', encoding='utf-8') as f: print(json.dumps(f.read(), ensure_ascii=False)) PY ) RELEASE_TITLE="${TAG}" if [ "${HOST}" = "github.com" ]; then RELEASE_PAYLOAD="{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${TARGET_COMMITISH}\",\"name\":\"${RELEASE_TITLE}\",\"body\":${BODY_JSON},\"draft\":false,\"prerelease\":false}" else RELEASE_PAYLOAD="{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${TARGET_COMMITISH}\",\"name\":\"${RELEASE_TITLE}\",\"body\":${BODY_JSON},\"draft\":false,\"prerelease\":false}" fi echo ">>> Создание релиза для тега ${TAG}..." RELEASE_JSON=$(curl -sS -X POST \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json" \ "${EXTRA_HEADERS[@]}" \ "${CREATE_URL}" \ -d "${RELEASE_PAYLOAD}") if ! printf '%s' "${RELEASE_JSON}" | python3 -c "import sys, json; j=json.load(sys.stdin); print(j.get('id',''));" >/tmp/release_id.txt 2>/dev/null; then echo "ОШИБКА: API вернул не-JSON при создании релиза" echo "Ответ: ${RELEASE_JSON}" exit 1 fi RELEASE_ID=$(cat /tmp/release_id.txt) rm -f /tmp/release_id.txt # If release already exists (e.g. Gitea auto-created it from tag push), # find it by tag if [ -z "${RELEASE_ID}" ] && [ "${HOST}" != "github.com" ]; then echo ">>> Релиз не создан (возможно уже существует), ищем по тегу ${TAG}..." RELEASE_JSON=$(curl -sS \ -H "${AUTH_HEADER}" \ "${SERVER}/api/v1/repos/${REPO}/releases/tags/${TAG}") if printf '%s' "${RELEASE_JSON}" | python3 -c "import sys, json; j=json.load(sys.stdin); print(j.get('id',''));" >/tmp/release_id.txt 2>/dev/null; then RELEASE_ID=$(cat /tmp/release_id.txt) fi rm -f /tmp/release_id.txt fi if [ -z "${RELEASE_ID}" ]; then echo "ОШИБКА: не удалось получить ID релиза" echo "${RELEASE_JSON}" exit 1 fi # Always PATCH the release to guarantee name + body are set # (handles race condition when parallel run creates empty release) echo ">>> Обновление релиза (ID: ${RELEASE_ID}) — name + body..." PATCH_PAYLOAD="{\"name\":\"${RELEASE_TITLE}\",\"body\":${BODY_JSON}}" if [ "${HOST}" = "github.com" ]; then PATCH_URL="https://api.github.com/repos/${REPO}/releases/${RELEASE_ID}" else PATCH_URL="${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}" fi PATCH_RESP=$(curl -sS -X PATCH \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/json" \ "${EXTRA_HEADERS[@]}" \ "${PATCH_URL}" \ -d "${PATCH_PAYLOAD}") PATCH_NAME=$(printf '%s' "${PATCH_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo '') echo ">>> Релиз обновлён (name: ${PATCH_NAME})" if [ "${HOST}" = "github.com" ]; then UPLOAD_BASE=$(printf '%s' "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('upload_url','').split('{')[0])") RELEASE_URL="https://github.com/${REPO}/releases/tag/${TAG}" else UPLOAD_BASE="${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" RELEASE_URL="${SERVER}/${REPO}/releases/tag/${TAG}" fi echo ">>> Загрузка файлов в релиз ${TAG}" for file in "${DIST_DIR}"/*; do [ -f "${file}" ] || continue name=$(basename "${file}") if [ "${HOST}" = "github.com" ]; then curl -sf -X POST \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/octet-stream" \ "${EXTRA_HEADERS[@]}" \ --data-binary @"${file}" \ "${UPLOAD_BASE}?name=${name}" >/dev/null else curl -sf -X POST \ -H "${AUTH_HEADER}" \ -H "Content-Type: application/octet-stream" \ --data-binary @"${file}" \ "${UPLOAD_BASE}?name=${name}" >/dev/null fi echo " ✓ ${name}" done echo "" echo ">>> Релиз опубликован: ${RELEASE_URL}"