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/десктоп — «собирается»); сборки докладывают файлы
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||||
|
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -321,3 +322,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
node scripts/release.mjs desktop linux cancelled
|
node scripts/release.mjs desktop linux cancelled
|
||||||
node scripts/release.mjs desktop windows 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.
|
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||||
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||||
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
|
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
|
||||||
|
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
|
||||||
|
Та же страница — внизу экрана входа
|
||||||
и в меню профиля.
|
и в меню профиля.
|
||||||
|
|
||||||
## 7. Наблюдение
|
## 7. Наблюдение
|
||||||
|
|||||||
+71
-1
@@ -4,6 +4,7 @@
|
|||||||
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
||||||
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
|
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
|
||||||
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
// 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,
|
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||||||
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||||||
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
|
// 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_START = '<!-- android-status -->';
|
||||||
export const STATUS_END = '<!-- /android-status -->';
|
export const STATUS_END = '<!-- /android-status -->';
|
||||||
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
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) {
|
export function desktopStatusMarks(platform) {
|
||||||
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
||||||
@@ -194,6 +199,35 @@ export function renderNotes({
|
|||||||
return out.join('\n');
|
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
|
// CLI
|
||||||
|
|
||||||
@@ -270,6 +304,41 @@ async function create() {
|
|||||||
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
|
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) {
|
async function upload(release, file) {
|
||||||
const name = basename(file);
|
const name = basename(file);
|
||||||
for (const old of release.assets ?? []) {
|
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])) {
|
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
|
||||||
return desktop(args[0], args[1], args.slice(2));
|
return desktop(args[0], args[1], args.slice(2));
|
||||||
}
|
}
|
||||||
|
if (cmd === 'prune') return prune();
|
||||||
throw new Error(
|
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,
|
replaceAndroidStatus,
|
||||||
replaceDesktopStatus,
|
replaceDesktopStatus,
|
||||||
desktopStatusMarks,
|
desktopStatusMarks,
|
||||||
|
staleActionRuns,
|
||||||
|
staleReleases,
|
||||||
STATUS_END,
|
STATUS_END,
|
||||||
STATUS_START,
|
STATUS_START,
|
||||||
} from './release.mjs';
|
} from './release.mjs';
|
||||||
@@ -131,6 +133,39 @@ test('release notes', () => {
|
|||||||
assert.doesNotMatch(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', () => {
|
test('release notes for a first prerelease without changes', () => {
|
||||||
const notes = renderNotes({
|
const notes = renderNotes({
|
||||||
version: '0.1.0-rc.1',
|
version: '0.1.0-rc.1',
|
||||||
|
|||||||
Reference in new issue
Block a user