Notify ntfy with workflow facts and test dispatch

This commit is contained in:
2026-09-21 12:14:37 -07:00
parent 4fcbd2d2d2
commit 2d56fb7f29
5 changed files with 123 additions and 8 deletions
+17
View File
@@ -2,6 +2,11 @@ name: Mirror to Gitea
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
notification_test:
description: Send a test notification without running the mirror
type: boolean
default: false
schedule: schedule:
- cron: '0 */4 * * *' # Run every 4 hours - cron: '0 */4 * * *' # Run every 4 hours
@@ -18,8 +23,20 @@ jobs:
node-version: 20 node-version: 20
- name: Run Mirror Script - name: Run Mirror Script
id: sync
if: ${{ !inputs.notification_test }}
run: node mirror.js run: node mirror.js
env: env:
GH_PAT: ${{ secrets.GH_PAT }} GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ secrets.GITEA_URL }} GITEA_URL: ${{ secrets.GITEA_URL }}
- name: Notify via ntfy
if: ${{ always() }}
run: node notify.js
env:
NTFY_TOPIC_URL: ${{ secrets.NTFY_TOPIC_URL }}
WORKFLOW_KIND: mirror
STEP_OUTCOME: ${{ steps.sync.outcome }}
TEST_NOTIFICATION: ${{ inputs.notification_test }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+10
View File
@@ -15,8 +15,18 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Rename mirrors missing on GitHub - name: Rename mirrors missing on GitHub
id: sync
run: node rename-gone-mirrors.js run: node rename-gone-mirrors.js
env: env:
GH_PAT: ${{ secrets.GH_PAT }} GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ secrets.GITEA_URL }} GITEA_URL: ${{ secrets.GITEA_URL }}
- name: Notify via ntfy
if: ${{ always() }}
run: node notify.js
env:
NTFY_TOPIC_URL: ${{ secrets.NTFY_TOPIC_URL }}
WORKFLOW_KIND: rename
STEP_OUTCOME: ${{ steps.sync.outcome }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+25 -2
View File
@@ -1,5 +1,10 @@
const syncOrgAvatar = require('./sync-org-avatar'); const syncOrgAvatar = require('./sync-org-avatar');
const { writeFileSync } = require('node:fs');
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env; const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
const facts = {
orgsChecked: 0, orgsCreated: 0, orgVisibilityChanged: 0, avatarsUpdated: 0,
reposFound: 0, mirrorsCreated: 0, repoVisibilityChanged: 0, failures: 0, changes: []
};
const G_API = `https://${GITEA_URL.replace(/^https?:\/\//, '')}/api/v1`; const G_API = `https://${GITEA_URL.replace(/^https?:\/\//, '')}/api/v1`;
const headers = { const headers = {
GH: { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' }, GH: { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' },
@@ -44,19 +49,28 @@ const getPages = async (url) => {
let avatarFailures = 0, repoFailures = 0; let avatarFailures = 0, repoFailures = 0;
for (const org of ghOrgs) { for (const org of ghOrgs) {
facts.orgsChecked++;
console.log(`Checking Org: ${org.login}`); console.log(`Checking Org: ${org.login}`);
try { try {
const gOrg = await req(`${G_API}/orgs/${org.login}`, headers.GT, 'GET', null, true); const gOrg = await req(`${G_API}/orgs/${org.login}`, headers.GT, 'GET', null, true);
if (!gOrg) { if (!gOrg) {
console.log(`Creating Org: ${org.login}`); console.log(`Creating Org: ${org.login}`);
await req(`${G_API}/orgs`, headers.GT, 'POST', { username: org.login, visibility: 'public' }); await req(`${G_API}/orgs`, headers.GT, 'POST', { username: org.login, visibility: 'public' });
facts.orgsCreated++;
facts.changes.push(`Created org ${org.login}`);
} else if (gOrg.visibility !== 'public') { } else if (gOrg.visibility !== 'public') {
console.log(`Updating Org Visibility: ${org.login}`); console.log(`Updating Org Visibility: ${org.login}`);
await req(`${G_API}/orgs/${org.login}`, headers.GT, 'PATCH', { visibility: 'public' }); await req(`${G_API}/orgs/${org.login}`, headers.GT, 'PATCH', { visibility: 'public' });
facts.orgVisibilityChanged++;
facts.changes.push(`Made org ${org.login} public`);
} }
try { try {
if (await syncOrgAvatar(org, G_API, headers.GT)) console.log(`Updated Org Avatar: ${org.login}`); if (await syncOrgAvatar(org, G_API, headers.GT)) {
facts.avatarsUpdated++;
facts.changes.push(`Updated avatar for ${org.login}`);
console.log(`Updated Org Avatar: ${org.login}`);
}
} catch (e) { } catch (e) {
avatarFailures++; avatarFailures++;
console.error(`Failed to sync avatar for ${org.login}:`, e.message); console.error(`Failed to sync avatar for ${org.login}:`, e.message);
@@ -70,6 +84,7 @@ const getPages = async (url) => {
} }
console.log(`Processing ${allRepos.length} repositories...`); console.log(`Processing ${allRepos.length} repositories...`);
facts.reposFound = allRepos.length;
for (const r of allRepos) { for (const r of allRepos) {
try { try {
const owner = r.owner.login; const owner = r.owner.login;
@@ -96,22 +111,30 @@ const getPages = async (url) => {
mirror_prune: true mirror_prune: true
}; };
await req(`${G_API}/repos/migrate`, headers.GT, 'POST', payload); await req(`${G_API}/repos/migrate`, headers.GT, 'POST', payload);
facts.mirrorsCreated++;
facts.changes.push(`Mirrored ${owner}/${r.name}`);
} else if (exists.private !== r.private) { } else if (exists.private !== r.private) {
console.log(`Updating visibility: ${owner}/${r.name} → ${r.private ? 'private' : 'public'}`); console.log(`Updating visibility: ${owner}/${r.name} → ${r.private ? 'private' : 'public'}`);
await req(`${G_API}/repos/${owner}/${r.name}`, headers.GT, 'PATCH', { private: r.private }); await req(`${G_API}/repos/${owner}/${r.name}`, headers.GT, 'PATCH', { private: r.private });
facts.repoVisibilityChanged++;
facts.changes.push(`${owner}/${r.name} is now ${r.private ? 'private' : 'public'}`);
} }
} catch (e) { } catch (e) {
repoFailures++; repoFailures++;
console.error(`Failed to mirror ${r.owner.login}/${r.name}:`, e.message); console.error(`Failed to mirror ${r.owner.login}/${r.name}:`, e.message);
} }
} }
facts.failures = avatarFailures + repoFailures;
if (avatarFailures || repoFailures) { if (avatarFailures || repoFailures) {
console.error(`Sync finished with ${avatarFailures} avatar failure(s) and ${repoFailures} repository failure(s).`); console.error(`Sync finished with ${avatarFailures} avatar failure(s) and ${repoFailures} repository failure(s).`);
process.exitCode = 1; process.exitCode = 1;
} }
} catch (err) { } catch (err) {
console.error('Fatal Error:', err); console.error('Fatal Error:', err);
process.exit(1); facts.fatal = true;
process.exitCode = 1;
} finally {
if (process.env.GITHUB_ACTIONS) writeFileSync('mirror-facts.json', JSON.stringify(facts));
} }
if (!process.exitCode) console.log('Sync Complete.'); if (!process.exitCode) console.log('Sync Complete.');
})(); })();
+56
View File
@@ -0,0 +1,56 @@
const { readFileSync } = require('node:fs');
const { NTFY_TOPIC_URL, RUN_URL, STEP_OUTCOME, TEST_NOTIFICATION, WORKFLOW_KIND } = process.env;
const labels = {
mirror: ['Mirror sync', 'mirror-facts.json', [
['Organizations checked', 'orgsChecked'],
['GitHub repositories found', 'reposFound'],
['Organizations created', 'orgsCreated'],
['Org visibility changes', 'orgVisibilityChanged'],
['Org avatars updated', 'avatarsUpdated'],
['New mirrors', 'mirrorsCreated'],
['Repo visibility changes', 'repoVisibilityChanged'],
['Failures', 'failures']
]],
rename: ['Missing-mirror check', 'rename-facts.json', [
['Gitea organizations checked', 'orgsChecked'],
['GitHub mirrors found', 'mirrorsFound'],
['Renamed mirrors', 'renamed'],
['Organizations skipped', 'skippedOrgs'],
['Failures', 'failures']
]]
};
(async () => {
if (!NTFY_TOPIC_URL || !labels[WORKFLOW_KIND]) throw new Error('Notification URL or workflow kind missing');
const test = TEST_NOTIFICATION === 'true';
const [label, file, fields] = labels[WORKFLOW_KIND];
const title = test ? 'Gitea backup notification test' : `${label}: ${STEP_OUTCOME === 'success' ? 'succeeded' : 'failed'}`;
const lines = test ? ['Notification-only test. No mirror or rename ran.'] : [];
if (!test) {
try {
const facts = JSON.parse(readFileSync(file, 'utf8'));
lines.push(...fields.map(([name, key]) => `${name}: ${facts[key] ?? 0}`));
const changes = facts.changes || [];
if (changes.length) lines.push('Changes:', ...changes.slice(0, 10));
if (changes.length > 10) lines.push(`...and ${changes.length - 10} more; see run logs.`);
if (facts.fatal) lines.push('Fatal error; see run logs.');
} catch {
lines.push('No run summary available; see run logs.');
}
}
if (RUN_URL) lines.push(`Run: ${RUN_URL}`);
const res = await fetch(NTFY_TOPIC_URL, {
method: 'POST',
headers: { Priority: '2', Title: title, ...(RUN_URL && { Click: RUN_URL }) },
body: lines.join('\n'),
signal: AbortSignal.timeout(30_000)
});
if (!res.ok) throw new Error(`ntfy returned HTTP ${res.status}`);
console.log(`ntfy notification sent: ${title}`);
})().catch(error => {
console.error(`Notification failed: ${error.message}`);
process.exitCode = 1;
});
+15 -6
View File
@@ -1,3 +1,4 @@
const { writeFileSync } = require('node:fs');
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env; const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
if (![GH_PAT, GITEA_TOKEN, GITEA_URL].every(Boolean)) { if (![GH_PAT, GITEA_TOKEN, GITEA_URL].every(Boolean)) {
throw new Error('GH_PAT, GITEA_TOKEN, and GITEA_URL are required'); throw new Error('GH_PAT, GITEA_TOKEN, and GITEA_URL are required');
@@ -8,6 +9,7 @@ const github = 'https://api.github.com';
const ghHeaders = { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' }; const ghHeaders = { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' };
const gtHeaders = { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' }; const gtHeaders = { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' };
const key = value => value.toLowerCase(); const key = value => value.toLowerCase();
const facts = { orgsChecked: 0, skippedOrgs: 0, mirrorsFound: 0, renamed: 0, failures: 0, changes: [] };
const request = async (url, headers, method = 'GET', body, missingOK = false) => { const request = async (url, headers, method = 'GET', body, missingOK = false) => {
const res = await fetch(url, { const res = await fetch(url, {
@@ -86,6 +88,7 @@ const alreadyGone = (name, original) =>
const ghOrgNames = new Set(ghOrgs.map(org => key(org.login))); const ghOrgNames = new Set(ghOrgs.map(org => key(org.login)));
const groups = [{ owner: gtUser.login, repos: gtPersonal, ghRepos: personal }]; const groups = [{ owner: gtUser.login, repos: gtPersonal, ghRepos: personal }];
for (const org of gtOrgs) { for (const org of gtOrgs) {
facts.orgsChecked++;
const owner = org.username || org.name; const owner = org.username || org.name;
if (ghOrgNames.has(key(owner))) { if (ghOrgNames.has(key(owner))) {
const [ghOrg, ghRepos, gtRepos] = await Promise.all([ const [ghOrg, ghRepos, gtRepos] = await Promise.all([
@@ -101,6 +104,7 @@ const alreadyGone = (name, original) =>
} else { } else {
const ghOrg = await request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders, 'GET', null, true); const ghOrg = await request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders, 'GET', null, true);
if (ghOrg) { if (ghOrg) {
facts.skippedOrgs++;
console.log(`Skipping ${owner}: GitHub organization exists but is not listed for this token`); console.log(`Skipping ${owner}: GitHub organization exists but is not listed for this token`);
continue; continue;
} }
@@ -109,13 +113,14 @@ const alreadyGone = (name, original) =>
} }
} }
let renamed = 0, failures = 0;
for (const { owner, repos, ghRepos } of groups) { for (const { owner, repos, ghRepos } of groups) {
const live = new Set(ghRepos.map(repo => key(repo.name))); const live = new Set(ghRepos.map(repo => key(repo.name)));
const occupied = new Set(repos.map(repo => key(repo.name))); const occupied = new Set(repos.map(repo => key(repo.name)));
for (const repo of repos) { for (const repo of repos) {
const remote = source(repo); const remote = source(repo);
if (!remote || key(repo.owner.login) !== key(owner) || live.has(key(remote.name))) continue; if (!remote || key(repo.owner.login) !== key(owner)) continue;
facts.mirrorsFound++;
if (live.has(key(remote.name))) continue;
if (alreadyGone(repo.name, remote.name)) continue; if (alreadyGone(repo.name, remote.name)) continue;
try { try {
@@ -134,17 +139,21 @@ const alreadyGone = (name, original) =>
if (key(updated.name) !== key(name)) throw new Error('Gitea did not return the requested name'); if (key(updated.name) !== key(name)) throw new Error('Gitea did not return the requested name');
occupied.delete(key(repo.name)); occupied.delete(key(repo.name));
occupied.add(key(name)); occupied.add(key(name));
renamed++; facts.renamed++;
facts.changes.push(`${owner}/${repo.name} → ${owner}/${name}`);
console.log(`Renamed ${owner}/${repo.name} to ${owner}/${name}`); console.log(`Renamed ${owner}/${repo.name} to ${owner}/${name}`);
} catch (error) { } catch (error) {
failures++; facts.failures++;
console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`); console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`);
} }
} }
} }
console.log(`Renamed ${renamed} missing GitHub mirror(s); ${failures} failure(s).`); console.log(`Renamed ${facts.renamed} missing GitHub mirror(s); ${facts.failures} failure(s).`);
if (failures) process.exitCode = 1; if (facts.failures) process.exitCode = 1;
})().catch(error => { })().catch(error => {
console.error(`Fatal: ${error.message}`); console.error(`Fatal: ${error.message}`);
facts.fatal = true;
process.exitCode = 1; process.exitCode = 1;
}).finally(() => {
if (process.env.GITHUB_ACTIONS) writeFileSync('rename-facts.json', JSON.stringify(facts));
}); });