ci(release): publish the release first, then attach the APK
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 29s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / api (push) Successful in 1m23s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m33s
release / release (заметки) (push) Failing after 5s
release / android (APK в релиз) (push) Skipped
release / verify (push) Successful in 1m19s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 29s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / api (push) Successful in 1m23s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m33s
release / release (заметки) (push) Failing after 5s
release / android (APK в релиз) (push) Skipped
release / verify (push) Successful in 1m19s
The release job now creates the Gitea release right after verification with generated notes: Android build status, changes grouped by Conventional Commit type with commit links, server update steps and APK install/check notes. The android job attaches the APK and .sha256 and rewrites only the status block (building / built with size, versionCode and SHA-256 / failed with a log link). scripts/release.mjs is idempotent on re-runs and unit-tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
622eb84bc9
commit
d3b2e7bfc3
5 files changed
+424
-55
No files matched your search
@@ -1,4 +1,6 @@
|
|||||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release.
|
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → Gitea Release с заметками → APK.
|
||||||
|
# Релиз создаётся сразу после проверок (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||||
|
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -42,16 +44,43 @@ jobs:
|
|||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
|
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
|
||||||
|
|
||||||
android-release:
|
release:
|
||||||
name: android + release
|
name: release (заметки)
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# вся история и теги: заметки строятся по коммитам с прошлого релиза
|
||||||
|
with: { fetch-depth: 0 }
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||||
|
- name: Gitea Release
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||||
|
run: node scripts/release.mjs create
|
||||||
|
|
||||||
|
android:
|
||||||
|
name: android (APK в релиз)
|
||||||
|
needs: [verify, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PIPU_VERSION: ${{ needs.verify.outputs.version }}
|
PIPU_VERSION: ${{ needs.verify.outputs.version }}
|
||||||
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
||||||
|
# для scripts/release.mjs
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with: { fetch-depth: 0 }
|
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: node scripts/release.mjs android building
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with: { distribution: temurin, java-version: "17" }
|
with: { distribution: temurin, java-version: "17" }
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
@@ -106,9 +135,11 @@ jobs:
|
|||||||
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
||||||
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
||||||
rm release.keystore
|
rm release.keystore
|
||||||
|
echo "SIGNING=release" >> "$GITHUB_ENV"
|
||||||
elif [ "$PRERELEASE" = "true" ]; then
|
elif [ "$PRERELEASE" = "true" ]; then
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
||||||
cp "$src" "$out"
|
cp "$src" "$out"
|
||||||
|
echo "SIGNING=debug" >> "$GITHUB_ENV"
|
||||||
else
|
else
|
||||||
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -117,41 +148,9 @@ jobs:
|
|||||||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||||||
echo "APK=$out" >> "$GITHUB_ENV"
|
echo "APK=$out" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Changelog
|
- name: APK в релиз
|
||||||
env:
|
run: node scripts/release.mjs android success "$APK"
|
||||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
|
||||||
run: |
|
|
||||||
# для финального релиза — изменения с прошлого финального, а не с последнего rc
|
|
||||||
exclude=()
|
|
||||||
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
|
|
||||||
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
|
||||||
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
|
||||||
{
|
|
||||||
echo "## Pipu Music ${PIPU_VERSION}"
|
|
||||||
echo
|
|
||||||
for t in feat fix perf refactor docs ci chore; do
|
|
||||||
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
|
|
||||||
[ -n "$lines" ] || continue
|
|
||||||
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
|
|
||||||
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
|
|
||||||
done
|
|
||||||
} > CHANGELOG_RELEASE.md
|
|
||||||
cat CHANGELOG_RELEASE.md
|
|
||||||
|
|
||||||
- name: Gitea Release
|
- name: Статус в релизе — сборка не удалась
|
||||||
env:
|
if: failure()
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
run: node scripts/release.mjs android failure
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
|
|
||||||
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
|
|
||||||
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
|
|
||||||
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
|
||||||
-d "$body" "$API/releases" | jq -r .id)"
|
|
||||||
for f in "$APK" "$APK.sha256"; do
|
|
||||||
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
|
|
||||||
echo "uploaded $(basename "$f")"
|
|
||||||
done
|
|
||||||
+18
-13
@@ -48,20 +48,25 @@ on:
|
|||||||
tags: ['v*.*.*']
|
tags: ['v*.*.*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify: # все проверки ci.yml (reusable workflow)
|
verify: # все проверки ci.yml
|
||||||
|
release: # needs: verify — сразу создаёт Gitea Release (scripts/release.mjs create)
|
||||||
android-release: # needs: verify
|
android: # needs: verify, release — собирает APK и докладывает его в релиз
|
||||||
# setup-java 17, Android SDK, pnpm install
|
|
||||||
# VERSION / VERSION_CODE из тега → app.config.ts
|
|
||||||
# expo prebuild --platform android --clean
|
|
||||||
# ./gradlew assembleRelease (signingConfig из секретов)
|
|
||||||
# apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256
|
|
||||||
|
|
||||||
# changelog из Conventional Commits между тегами
|
|
||||||
# создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу
|
|
||||||
# загрузить ассеты: APK и .sha256
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Релиз появляется сразу после проверок, не дожидаясь APK. Текст строит `scripts/release.mjs`:
|
||||||
|
|
||||||
|
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
|
||||||
|
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
|
||||||
|
- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
|
||||||
|
«Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
|
||||||
|
финального, а не от последнего `-rc`; ссылка на полный diff;
|
||||||
|
- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
|
||||||
|
|
||||||
|
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease` (только arm64-v8a), подпись `apksigner`,
|
||||||
|
затем `release.mjs android success <apk>` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
|
||||||
|
`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
|
||||||
|
Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
|
||||||
|
|
||||||
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
|
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
|
||||||
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
|
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
|
||||||
|
|
||||||
@@ -69,7 +74,7 @@ Docker-образы **не публикуются**: сервер собирае
|
|||||||
|
|
||||||
| Секрет | Назначение |
|
| Секрет | Назначение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
|
| `RELEASE_TOKEN` | Создание и редактирование релиза, загрузка ассетов (scope `write:repository`) |
|
||||||
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
|
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"generate": "turbo run generate",
|
"generate": "turbo run generate",
|
||||||
"i18n:check": "pnpm --filter @pipu/i18n check",
|
"i18n:check": "pnpm --filter @pipu/i18n check",
|
||||||
"version:info": "node scripts/version.mjs",
|
"version:info": "node scripts/version.mjs",
|
||||||
"test:scripts": "node --test scripts/version.test.mjs"
|
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "2.5.4",
|
"turbo": "2.5.4",
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
|
||||||
|
// докладывает в него APK и переписывает блок статуса в тексте.
|
||||||
|
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
||||||
|
// node scripts/release.mjs android building|failure — статус сборки APK в тексте релиза
|
||||||
|
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
||||||
|
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||||||
|
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { basename, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
['feat', 'Новое'],
|
||||||
|
['fix', 'Исправления'],
|
||||||
|
['perf', 'Производительность'],
|
||||||
|
['refactor', 'Рефакторинг'],
|
||||||
|
['docs', 'Документация'],
|
||||||
|
['build', 'Сборка и CI'],
|
||||||
|
['ci', 'Сборка и CI'],
|
||||||
|
];
|
||||||
|
const OTHER = 'Прочее';
|
||||||
|
const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||||||
|
|
||||||
|
export const STATUS_START = '<!-- android-status -->';
|
||||||
|
export const STATUS_END = '<!-- /android-status -->';
|
||||||
|
|
||||||
|
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||||||
|
export function groupCommits(commits) {
|
||||||
|
const breaking = [];
|
||||||
|
const byTitle = new Map();
|
||||||
|
for (const { sha, subject } of commits) {
|
||||||
|
const m = COMMIT_RE.exec(subject);
|
||||||
|
const item = m ? { sha, scope: m[2] ?? null, text: m[4] } : { sha, scope: null, text: subject };
|
||||||
|
if (m?.[3]) breaking.push(item);
|
||||||
|
const title = (m && SECTIONS.find(([t]) => t === m[1])?.[1]) ?? OTHER;
|
||||||
|
if (!byTitle.has(title)) byTitle.set(title, []);
|
||||||
|
byTitle.get(title).push(item);
|
||||||
|
}
|
||||||
|
const order = [...new Set(SECTIONS.map(([, t]) => t)), OTHER];
|
||||||
|
return { breaking, sections: order.filter((t) => byTitle.has(t)).map((t) => [t, byTitle.get(t)]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} МБ`;
|
||||||
|
|
||||||
|
/** Блок статуса сборки Android в тексте релиза. */
|
||||||
|
export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing }) {
|
||||||
|
const lines =
|
||||||
|
status === 'success'
|
||||||
|
? [
|
||||||
|
`✅ **Android:** APK собран — \`${apk.name}\` (${formatSize(apk.size)}), versionCode \`${versionCode}\`.`,
|
||||||
|
`SHA-256: \`${apk.sha256}\``,
|
||||||
|
...(signing === 'debug'
|
||||||
|
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: status === 'failure'
|
||||||
|
? [
|
||||||
|
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
|
||||||
|
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
|
||||||
|
'Файл появится в этом релизе автоматически, а этот текст обновится.',
|
||||||
|
];
|
||||||
|
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||||
|
export function replaceAndroidStatus(body, block) {
|
||||||
|
const start = body.indexOf(STATUS_START);
|
||||||
|
const end = body.indexOf(STATUS_END);
|
||||||
|
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||||
|
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
|
||||||
|
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
|
||||||
|
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||||||
|
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (prerelease) {
|
||||||
|
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||||||
|
}
|
||||||
|
out.push(androidStatus, '', '## Что изменилось', '');
|
||||||
|
if (groups.breaking.length) {
|
||||||
|
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||||||
|
}
|
||||||
|
if (groups.sections.length) {
|
||||||
|
for (const [title, items] of groups.sections) out.push(`### ${title}`, '', ...items.map(item), '');
|
||||||
|
} else {
|
||||||
|
out.push('Изменений с прошлой версии нет.', '');
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
prevTag
|
||||||
|
? `Все изменения: [${prevTag}...${tag}](${repoUrl}/compare/${prevTag}...${tag})`
|
||||||
|
: `Все изменения: [история ${tag}](${repoUrl}/commits/tag/${tag})`,
|
||||||
|
'',
|
||||||
|
'## Обновление сервера',
|
||||||
|
'',
|
||||||
|
'Перед обновлением сделайте бэкап базы:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'cd /opt/pipu-music/deploy && docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'Обновление:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
|
||||||
|
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'curl -s https://<ваш-домен>/api/v1/server-info',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
`Ожидается \`"version":"${version}"\`.`,
|
||||||
|
'',
|
||||||
|
'## Android',
|
||||||
|
'',
|
||||||
|
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
|
||||||
|
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
|
||||||
|
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||||
|
'',
|
||||||
|
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------
|
||||||
|
// CLI
|
||||||
|
|
||||||
|
function env(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) throw new Error(`не задана переменная окружения ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, { json, form } = {}) {
|
||||||
|
const res = await fetch(`${env('GITEA_API')}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${env('GITEA_TOKEN')}`,
|
||||||
|
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: json ? JSON.stringify(json) : form,
|
||||||
|
});
|
||||||
|
if (res.status === 404 && method === 'GET') return null;
|
||||||
|
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return res.status === 204 ? null : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
|
function previousTag(tag, prerelease) {
|
||||||
|
// для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||||||
|
const exclude = prerelease ? [] : ['--exclude', '*-rc*'];
|
||||||
|
try {
|
||||||
|
return git('describe', '--tags', '--abbrev=0', ...exclude, `${tag}^`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitsBetween(prevTag, tag) {
|
||||||
|
const out = git('log', '--no-merges', '--format=%H%x09%s', prevTag ? `${prevTag}..${tag}` : tag);
|
||||||
|
return out
|
||||||
|
? out.split('\n').map((l) => {
|
||||||
|
const [sha, ...rest] = l.split('\t');
|
||||||
|
return { sha, subject: rest.join('\t') };
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function context() {
|
||||||
|
const repo = await api('GET', '');
|
||||||
|
const tag = env('TAG');
|
||||||
|
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
|
||||||
|
return { repoUrl: repo.html_url, tag, release, runUrl: `${repo.html_url}/actions/runs/${env('GITHUB_RUN_ID')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const { repoUrl, tag, release, runUrl } = await context();
|
||||||
|
const prerelease = env('PRERELEASE') === 'true';
|
||||||
|
const prevTag = previousTag(tag, prerelease);
|
||||||
|
const body = renderNotes({
|
||||||
|
version: env('VERSION'),
|
||||||
|
tag,
|
||||||
|
prevTag,
|
||||||
|
prerelease,
|
||||||
|
repoUrl,
|
||||||
|
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||||||
|
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||||||
|
});
|
||||||
|
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||||||
|
const saved = release
|
||||||
|
? await api('PATCH', `/releases/${release.id}`, { json: fields })
|
||||||
|
: await api('POST', '/releases', { json: { tag_name: tag, ...fields } });
|
||||||
|
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(release, file) {
|
||||||
|
const name = basename(file);
|
||||||
|
for (const old of release.assets ?? []) {
|
||||||
|
if (old.name === name) await api('DELETE', `/releases/${release.id}/assets/${old.id}`);
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('attachment', new Blob([readFileSync(file)]), name);
|
||||||
|
await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { form });
|
||||||
|
console.log(`приложен ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function android(status, apkPath) {
|
||||||
|
const { release, runUrl } = await context();
|
||||||
|
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||||||
|
let apk;
|
||||||
|
if (status === 'success') {
|
||||||
|
if (!apkPath) throw new Error('android success: укажите путь к APK');
|
||||||
|
const data = readFileSync(apkPath);
|
||||||
|
apk = { name: basename(apkPath), size: data.length, sha256: createHash('sha256').update(data).digest('hex') };
|
||||||
|
for (const f of [apkPath, `${apkPath}.sha256`]) if (existsSync(f)) await upload(release, f);
|
||||||
|
}
|
||||||
|
const block = renderAndroidStatus({
|
||||||
|
status,
|
||||||
|
runUrl,
|
||||||
|
apk,
|
||||||
|
versionCode: process.env.VERSION_CODE,
|
||||||
|
signing: process.env.SIGNING,
|
||||||
|
});
|
||||||
|
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceAndroidStatus(release.body ?? '', block) } });
|
||||||
|
console.log(`статус Android: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
|
if (cmd === 'create') return create();
|
||||||
|
if (cmd === 'android' && ['building', 'success', 'failure'].includes(args[0])) return android(args[0], args[1]);
|
||||||
|
throw new Error('использование: release.mjs create | android building|failure | android success <file.apk>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||||
|
main().catch((e) => {
|
||||||
|
process.stderr.write(`${e.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
groupCommits,
|
||||||
|
renderAndroidStatus,
|
||||||
|
renderNotes,
|
||||||
|
replaceAndroidStatus,
|
||||||
|
STATUS_END,
|
||||||
|
STATUS_START,
|
||||||
|
} from './release.mjs';
|
||||||
|
|
||||||
|
const sha = (n) => String(n).repeat(40).slice(0, 40);
|
||||||
|
const repoUrl = 'https://git.example/owner/pipu-music';
|
||||||
|
|
||||||
|
test('groups conventional commits by section in a fixed order', () => {
|
||||||
|
const groups = groupCommits([
|
||||||
|
{ sha: sha(1), subject: 'fix(api): handle empty token' },
|
||||||
|
{ sha: sha(2), subject: 'feat: add wave' },
|
||||||
|
{ sha: sha(3), subject: 'ci(release): build arm64 only' },
|
||||||
|
{ sha: sha(4), subject: 'build(deploy): pin images' },
|
||||||
|
{ sha: sha(5), subject: 'Update README' },
|
||||||
|
{ sha: sha(6), subject: 'chore: bump deps' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
groups.sections.map(([title, items]) => [title, items.map((i) => i.text)]),
|
||||||
|
[
|
||||||
|
['Новое', ['add wave']],
|
||||||
|
['Исправления', ['handle empty token']],
|
||||||
|
['Сборка и CI', ['build arm64 only', 'pin images']],
|
||||||
|
['Прочее', ['Update README', 'bump deps']],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(groups.sections[1][1][0].scope, 'api');
|
||||||
|
assert.deepEqual(groups.breaking, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks breaking changes', () => {
|
||||||
|
const groups = groupCommits([{ sha: sha(1), subject: 'feat(api)!: drop v0 endpoints' }]);
|
||||||
|
assert.equal(groups.breaking.length, 1);
|
||||||
|
assert.equal(groups.sections[0][0], 'Новое');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('android status blocks', () => {
|
||||||
|
const building = renderAndroidStatus({ status: 'building', runUrl: 'https://run' });
|
||||||
|
assert.ok(building.startsWith(STATUS_START) && building.endsWith(STATUS_END));
|
||||||
|
assert.match(building, /собирается — \[ход сборки\]\(https:\/\/run\)/);
|
||||||
|
|
||||||
|
const ok = renderAndroidStatus({
|
||||||
|
status: 'success',
|
||||||
|
versionCode: '10299',
|
||||||
|
signing: 'release',
|
||||||
|
apk: { name: 'pipu-music-0.1.2.apk', size: 25 * 1024 * 1024 + 300_000, sha256: 'ab'.repeat(32) },
|
||||||
|
});
|
||||||
|
assert.match(ok, /`pipu-music-0\.1\.2\.apk` \(25,3 МБ\), versionCode `10299`/);
|
||||||
|
assert.doesNotMatch(ok, /отладочным/);
|
||||||
|
|
||||||
|
const debug = renderAndroidStatus({ status: 'success', signing: 'debug', apk: { name: 'a.apk', size: 1, sha256: 'x' } });
|
||||||
|
assert.match(debug, /отладочным ключом/);
|
||||||
|
|
||||||
|
assert.match(renderAndroidStatus({ status: 'failure', runUrl: 'https://run' }), /не удалась — \[лог сборки\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces only the status block and is idempotent', () => {
|
||||||
|
const body = `intro\n\n${renderAndroidStatus({ status: 'building', runUrl: 'u' })}\n\n## Что изменилось\n`;
|
||||||
|
const failed = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
|
||||||
|
const once = replaceAndroidStatus(body, failed);
|
||||||
|
assert.equal(replaceAndroidStatus(once, failed), once);
|
||||||
|
assert.ok(once.startsWith('intro\n\n') && once.endsWith('\n\n## Что изменилось\n'));
|
||||||
|
assert.doesNotMatch(once, /собирается/);
|
||||||
|
assert.equal(once.split(STATUS_START).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a status block when the body has none', () => {
|
||||||
|
const block = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
|
||||||
|
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release notes', () => {
|
||||||
|
const notes = renderNotes({
|
||||||
|
version: '0.2.0',
|
||||||
|
tag: 'v0.2.0',
|
||||||
|
prevTag: 'v0.1.7',
|
||||||
|
prerelease: false,
|
||||||
|
repoUrl,
|
||||||
|
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
||||||
|
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
||||||
|
});
|
||||||
|
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
||||||
|
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
||||||
|
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
||||||
|
assert.match(notes, /"version":"0\.2\.0"/);
|
||||||
|
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
||||||
|
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
||||||
|
assert.doesNotMatch(notes, /Предварительная версия/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release notes for a first prerelease without changes', () => {
|
||||||
|
const notes = renderNotes({
|
||||||
|
version: '0.1.0-rc.1',
|
||||||
|
tag: 'v0.1.0-rc.1',
|
||||||
|
prevTag: null,
|
||||||
|
prerelease: true,
|
||||||
|
repoUrl,
|
||||||
|
groups: groupCommits([]),
|
||||||
|
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'u' }),
|
||||||
|
});
|
||||||
|
assert.match(notes, /Предварительная версия/);
|
||||||
|
assert.match(notes, /Изменений с прошлой версии нет/);
|
||||||
|
assert.match(notes, /commits\/tag\/v0\.1\.0-rc\.1/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user