diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3f46a22..c7b24f2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,6 +2,7 @@ # Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы # и переписывают статус: собран / не удалась (scripts/release.mjs). # Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin). +# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions. # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # # Секреты (Settings → Actions → Secrets): @@ -321,3 +322,19 @@ jobs: run: | node scripts/release.mjs desktop linux cancelled node scripts/release.mjs desktop windows cancelled + + prune: + name: prune (старые файлы и прогоны) + needs: [release, android, desktop] + if: always() && needs.release.result == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: "${{ env.NODE_VERSION }}" } + - name: Оставить 8 релизов и 8 прогонов + env: + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: node scripts/release.mjs prune diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 4c48286..ae6ee5f 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -104,7 +104,9 @@ Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда): сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция. -APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа +APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI +оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет). +Та же страница — внизу экрана входа и в меню профиля. ## 7. Наблюдение diff --git a/scripts/release.mjs b/scripts/release.mjs index 07402af..a2a5e60 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -4,6 +4,7 @@ // node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить) // node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза // node scripts/release.mjs android success — приложить APK и .sha256, статус «собран» +// node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions // Окружение: GITEA_API (/api/v1/repos//), GITEA_TOKEN, TAG, VERSION, VERSION_CODE, // PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug). // PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url @@ -30,6 +31,10 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; export const STATUS_START = ''; export const STATUS_END = ''; export const DESKTOP_PLATFORMS = ['linux', 'windows']; +/** Сколько последних опубликованных релизов оставляем с файлами (APK, .deb, NSIS). */ +export const KEEP_RELEASE_FILES = 8; +/** Сколько последних прогонов Actions оставляем (логи/статусы). */ +export const KEEP_ACTION_RUNS = 8; export function desktopStatusMarks(platform) { if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`); @@ -194,6 +199,35 @@ export function renderNotes({ return out.join('\n'); } +function publishedAt(item) { + return Date.parse(item.published_at || item.created_at || item.started_at || 0); +} + +/** Релизы, с которых снимаем вложения: черновики не трогаем, текущий тег всегда оставляем. */ +export function staleReleases(releases, { keep = KEEP_RELEASE_FILES, currentTag } = {}) { + const published = releases.filter((r) => !r.draft); + published.sort((a, b) => publishedAt(b) - publishedAt(a)); + const keepIds = new Set(published.slice(0, keep).map((r) => r.id)); + const current = published.find((r) => r.tag_name === currentTag); + if (current) keepIds.add(current.id); + return published.filter((r) => !keepIds.has(r.id) && (r.assets?.length ?? 0) > 0); +} + +/** Завершённые прогоны Actions, которые можно удалить (текущий прогон не трогаем). */ +export function staleActionRuns(runs, { keep = KEEP_ACTION_RUNS, currentRunId } = {}) { + const done = runs.filter((r) => r.status === 'completed' && String(r.id) !== String(currentRunId ?? '')); + done.sort((a, b) => publishedAt(b) - publishedAt(a)); + const keepIds = new Set(done.slice(0, keep).map((r) => r.id)); + return done.filter((r) => !keepIds.has(r.id)); +} + +function actionRunsFrom(body) { + if (Array.isArray(body)) return body; + if (Array.isArray(body?.workflow_runs)) return body.workflow_runs; + if (Array.isArray(body?.runs)) return body.runs; + return []; +} + // --------------------------------------------------------------------------------------------------------- // CLI @@ -270,6 +304,41 @@ async function create() { console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`); } +async function listPages(path, itemsOf) { + const all = []; + for (let page = 1; page <= 40; page += 1) { + const sep = path.includes('?') ? '&' : '?'; + const body = await api('GET', `${path}${sep}limit=50&page=${page}`); + const batch = itemsOf(body); + if (!batch.length) break; + all.push(...batch); + if (batch.length < 50) break; + } + return all; +} + +async function prune() { + const tag = process.env.TAG; + const currentRunId = process.env.GITHUB_RUN_ID; + const releases = await listPages('/releases', (body) => (Array.isArray(body) ? body : [])); + let files = 0; + for (const rel of staleReleases(releases, { currentTag: tag })) { + for (const a of rel.assets ?? []) { + await api('DELETE', `/releases/${rel.id}/assets/${a.id}`); + files += 1; + console.log(`удалён файл ${rel.tag_name}/${a.name}`); + } + } + const runs = await listPages('/actions/runs', actionRunsFrom); + let statuses = 0; + for (const run of staleActionRuns(runs, { currentRunId })) { + await api('DELETE', `/actions/runs/${run.id}`); + statuses += 1; + console.log(`удалён прогон Actions ${run.id} (${run.path ?? run.name ?? run.event ?? ''})`); + } + console.log(`очистка: файлов ${files}, прогонов ${statuses}`); +} + async function upload(release, file) { const name = basename(file); for (const old of release.assets ?? []) { @@ -331,8 +400,9 @@ async function main() { if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) { return desktop(args[0], args[1], args.slice(2)); } + if (cmd === 'prune') return prune(); throw new Error( - 'использование: release.mjs create | android building|failure|cancelled | android success | desktop linux|windows building|failure|cancelled | desktop linux|windows success ', + 'использование: release.mjs create | android building|failure|cancelled | android success | desktop linux|windows building|failure|cancelled | desktop linux|windows success | prune', ); } diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index d02a8ad..c0c60b2 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -9,6 +9,8 @@ import { replaceAndroidStatus, replaceDesktopStatus, desktopStatusMarks, + staleActionRuns, + staleReleases, STATUS_END, STATUS_START, } from './release.mjs'; @@ -131,6 +133,39 @@ test('release notes', () => { assert.doesNotMatch(notes, /Предварительная версия/); }); +test('keeps files on the newest eight releases', () => { + const releases = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + tag_name: `v1.0.${i}`, + draft: false, + published_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + assets: i === 0 ? [] : [{ id: 100 + i, name: `a${i}.apk` }], + })); + const stale = staleReleases(releases, { keep: 8, currentTag: 'v1.0.9' }); + assert.deepEqual( + stale.map((r) => r.tag_name).sort(), + ['v1.0.1'], + ); +}); + +test('does not delete the current Actions run or unfinished jobs', () => { + const runs = [ + { id: 20, status: 'completed', created_at: '2026-01-20T00:00:00Z' }, + { id: 19, status: 'in_progress', created_at: '2026-01-19T00:00:00Z' }, + ...Array.from({ length: 12 }, (_, i) => ({ + id: i + 1, + status: 'completed', + created_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + })), + ]; + const stale = staleActionRuns(runs, { keep: 8, currentRunId: 20 }); + assert.equal(stale.some((r) => r.id === 20 || r.id === 19), false); + assert.deepEqual( + stale.map((r) => r.id).sort((a, b) => a - b), + [1, 2, 3, 4], + ); +}); + test('release notes for a first prerelease without changes', () => { const notes = renderNotes({ version: '0.1.0-rc.1',