Compare commits

...
1 Commits
Author SHA1 Message Date
mrleo1nidandCursor 8eb6b36888 ci(release): keep files and Actions runs for the last 8 releases
ci / ts / ts (packages + web) (push) Successful in 3m30s
release / android (APK в релиз) (push) Successful in 8m57s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m4s
release / prune (старые файлы и прогоны) (push) Successful in 8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:32:18 +03:00
4 changed files with 126 additions and 2 deletions

No files matched your search

+17
View File
@@ -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
+3 -1
View File
@@ -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. Наблюдение
+71 -1
View File
@@ -4,6 +4,7 @@
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
// node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), 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 = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
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 <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...> | prune',
);
}
+35
View File
@@ -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',