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
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>
This commit is contained in:
1 parent
83b604437c
commit
8eb6b36888
4 files changed
+126
-2
No files matched your search
@@ -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
@@ -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
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user