feat(health): re-check V1 plugins nightly and guard the sunset removal (#520)

* feat(health): re-check V1 plugins nightly and guard the sunset removal

Listed plugins kept their classified compat forever, so a plugin that
migrated after the V1 sunset notice would still be removed on 2027-01-01.
The nightly health job now re-detects every plugin not marked v2, opens a
bot/health-compat PR for the ones now on V2, removes V1 plugins after the
sunset only when the re-check still says V1, and keeps the v1-sunset
tracking issue current (one reminder 31 days before the sunset).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYZtiXaVB71TSBhHnynk4

* fix(health): sanitize logged values flagged by SonarCloud

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYZtiXaVB71TSBhHnynk4

---------

Co-authored-by: John Smith <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-17 15:32:22 -04:00
1 parent 869f933a18
commit d7ab2a718c
4 files changed
+203 -19

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
# Nightly catalog health: updates the "Catalog health report" issue, and opens bot PRs that follow
# renamed repos and remove entries dead for 30+ days (or V1-only plugins after the sunset date).
# renamed repos, mark plugins that migrated to V2, and remove entries dead for 30+ days (or V1-only
# plugins still detected as V1 after the sunset date). Also keeps the V1 sunset tracking issue current.
name: Catalog health
on:
+6 -3
View File
@@ -30,7 +30,9 @@ Themes, agents, projects and resources are not blocked on V2. Their `compat` is
## Existing V1 plugins: sunset on 2027-01-01
Plugins already in the catalog that are V1-only carry a `V1 only` badge in the README and on the site. **After 2027-01-01 they are removed** by the nightly health job's removal PR unless they have migrated (the check runs again before removal). Plugins with `unknown` compatibility are listed in the health report for manual verification before that date.
Plugins already in the catalog that are V1-only carry a `V1 only` badge in the README and on the site. The nightly health job re-detects every plugin not marked `v2`; when one is detected as V2 it opens a PR (`bot/health-compat`) marking the entry `compat: v2`. **After 2027-01-01 V1-only plugins are removed** by the health job's removal PR, but only when that night's re-check still detects V1: a V2 or inconclusive result keeps the entry, and inconclusive ones are listed in the health report for a maintainer. Plugins with `unknown` compatibility are listed there too for manual verification.
The bot keeps the list in the V1 sunset tracking issue (label `v1-sunset`) current, posts one reminder there 31 days before the sunset, and closes the issue once no V1-only plugins remain.
## Health checks and removal
@@ -40,7 +42,8 @@ A nightly job checks every entry and maintains the **Catalog health report** iss
| :--- | :--- |
| Repository renamed or transferred | Bot PR updates the `repo` URL |
| Repository archived, deleted (404), or npm package deprecated | Listed in the report; **removal PR after 30 days** if it doesn't recover |
| V1-only plugin after 2027-01-01 | Removal PR |
| Plugin not marked `v2` now detected as V2 | Bot PR sets `compat: v2` |
| V1-only plugin after 2027-01-01, still detected as V1 | Removal PR |
| No push for 6–12 months / 12+ months | `quiet` / `stale` badge on the site. **Never removed for inactivity alone**: small, finished tools are fine |
Removal PRs are reviewed like any other PR; closing one keeps the entries until the next run proposes them again. Git history is the archive; there is no separate retired list.
@@ -61,7 +64,7 @@ A weekly scout searches npm for packages published since V2 shipped that depend
| `triage.yml` | PR opened/updated | Labels and a results comment (base-branch code only). `needs-human` is only ever removed by a maintainer |
| `generate-readme.yml` | push to main | README.md and dist/registry.json; fails without writing on any invalid entry. The regenerated commit goes through a bot PR (`bot/readme-*`) that merges itself once `validate` passes |
| `site.yml` | push to main, daily | Search site on GitHub Pages |
| `health.yml` | daily | Health report issue, rename and removal PRs |
| `health.yml` | daily | Health report and V1 sunset issues; rename, V2-migration and removal PRs |
| `reprobe.yml` | daily | Re-checks `needs-v2` PRs |
| `scout.yml` | weekly | Scout issue with V2 plugin candidates |
| `issues.yml` | issue opened/edited | Issue-form submissions and scout acceptances become PRs |
+154 -15
View File
@@ -1,33 +1,42 @@
#!/usr/bin/env node
/**
* Catalog health: link rot, archived/deprecated targets, renames, staleness and the V1 sunset.
* Catalog health: link rot, archived/deprecated targets, renames, staleness, V2 migrations and the V1 sunset.
*
* Usage: node scripts/health-check.js [options]
* --out <file> write per-entry health JSON (used by the site)
* --issue create/update the rolling "Catalog health report" issue (holds grace-period state)
* and the V1 sunset tracking issue (label v1-sunset)
* --apply-renames rewrite repo URLs of renamed/transferred repos in the working tree
* --apply-compat mark plugins now detected as V2 with compat: v2 in the working tree
* --apply-removals delete entries dead for GRACE_DAYS+ (and V1 plugins after the sunset date)
* --open-prs open/update bot PRs for renames (bot/health-renames) and removals (bot/health-removals)
* --open-prs open/update bot PRs for renames (bot/health-renames), V2 migrations
* (bot/health-compat) and removals (bot/health-removals)
* --pr-body <file> write a markdown summary of the applied changes
* --dry-run print the report instead of writing the issue
*
* Policy (docs/policy.md): archived, 404 or npm-deprecated targets are removed after a 30-day grace
* period; renamed repos are updated automatically; inactivity alone never removes an entry.
* period; renamed repos are updated automatically; inactivity alone never removes an entry. Plugins not
* marked v2 are re-detected every night: a V2 detection opens a PR marking them v2, and after the sunset
* date a V1 plugin is only removed when that night's re-check confirms it is still V1.
*/
const fs = require('fs');
const path = require('path');
const { V1_SUNSET, loadCatalog, parseRepoUrl } = require('./utils/catalog');
const { probeCompat } = require('./utils/compat');
const { dumpEntry } = require('./utils/format');
const { getNpmLatest, getRepos, rest } = require('./utils/github');
const { openPullRequest } = require('./utils/git-pr');
const { REPO, ensureLabels } = require('./utils/repo');
const { REPO, comment, ensureLabels, listComments } = require('./utils/repo');
const ROOT = path.join(__dirname, '..');
const GRACE_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
const ISSUE_TITLE = 'Catalog health report';
const STATE_RE = /<!-- health-state (\{.*?\}) -->/s;
const POLICY = 'https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md';
const REMINDER_DAYS = 31;
const REMINDER_MARKER = '<!-- v1-sunset-reminder -->';
function parseArgs(argv) {
const value = flag => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : null);
@@ -35,6 +44,7 @@ function parseArgs(argv) {
out: value('--out'),
issue: argv.includes('--issue'),
applyRenames: argv.includes('--apply-renames'),
applyCompat: argv.includes('--apply-compat'),
applyRemovals: argv.includes('--apply-removals'),
openPrs: argv.includes('--open-prs'),
prBody: value('--pr-body'),
@@ -44,6 +54,7 @@ function parseArgs(argv) {
const today = () => new Date().toISOString().slice(0, 10);
const daysSince = date => Math.floor((Date.now() - new Date(date).getTime()) / DAY);
const daysUntil = date => Math.ceil((new Date(date).getTime() - Date.now()) / DAY);
async function checkUrl(url) {
try {
@@ -104,6 +115,21 @@ async function collect(entries) {
return health;
}
/**
* Re-detect the compatibility of every plugin not marked v2 (V1-only and unverified), so migrations are
* picked up and the sunset never removes a plugin that has moved to V2 since it was classified.
* @returns {Promise<Map<string, {compat: string, evidence: string[]}>>} keyed by entry file; empty if the probe failed
*/
async function recheckCompat(entries) {
const plugins = entries.filter(e => e.category === 'plugins' && e.data.compat !== 'v2');
try {
return await probeCompat(plugins.map(e => ({ key: e.file, repo: e.data.repo, path: e.data.path, npm: e.data.npm, plugin: true })));
} catch (err) {
console.error(`compat re-check failed: ${String(err.message).replace(/[\r\n]+/g, ' ')}`);
return new Map();
}
}
async function findIssue() {
const { data } = await rest(`/repos/${REPO}/issues?state=open&labels=health&per_page=20`);
return Array.isArray(data) ? data.find(i => i.title === ISSUE_TITLE && !i.pull_request) : null;
@@ -128,17 +154,29 @@ function updateState(previous, entries, health) {
return state;
}
function removalCandidates(entries, health, state) {
/**
* Entries to delete: dead for GRACE_DAYS+, or V1-only plugins past the sunset that tonight's re-check
* still detects as V1 (a V2 or inconclusive result keeps the entry).
*/
function removalCandidates(entries, state, compat, date = today()) {
const out = [];
for (const e of entries) {
const s = state[e.file];
const now = compat.get(e.file);
if (s && daysSince(s.since) >= GRACE_DAYS) out.push({ entry: e, reason: `${s.reason} since ${s.since}` });
else if (e.category === 'plugins' && e.data.compat === 'v1' && today() >= V1_SUNSET) out.push({ entry: e, reason: `V1-only plugin past the ${V1_SUNSET} sunset` });
else if (e.category === 'plugins' && e.data.compat === 'v1' && date >= V1_SUNSET && now?.compat === 'v1') {
out.push({ entry: e, reason: `V1-only plugin past the ${V1_SUNSET} sunset (${now.evidence.join('; ')})` });
}
}
return out;
}
function report(entries, health, state) {
/** Plugins not marked v2 that tonight's re-check detects as V2. */
function migratedPlugins(entries, compat) {
return entries.filter(e => e.category === 'plugins' && e.data.compat !== 'v2' && compat.get(e.file)?.compat === 'v2');
}
function report(entries, health, state, compat) {
const link = e => `[${e.data.name}](${e.data.repo}) \`${e.file}\``;
const rows = (list, fmt) => list.length ? list.map(fmt).join('\n') : '_None._';
const dead = entries.filter(e => state[e.file]);
@@ -146,6 +184,8 @@ function report(entries, health, state) {
const plugins = entries.filter(e => e.category === 'plugins');
const v1 = plugins.filter(e => e.data.compat === 'v1');
const unknown = plugins.filter(e => e.data.compat === 'unknown');
const migrated = migratedPlugins(entries, compat);
const unconfirmed = v1.filter(e => !['v1', 'v2'].includes(compat.get(e.file)?.compat));
const stale = entries.filter(e => health.get(e.file).status === 'stale');
const npmMissing = entries.filter(e => health.get(e.file).npmMissing);
@@ -157,7 +197,9 @@ function report(entries, health, state) {
`| Entries | ${entries.length} |`,
`| Archived, missing or deprecated (removed after ${GRACE_DAYS} days) | ${dead.length} |`,
`| Renamed or transferred (fixed by bot PR) | ${renamed.length} |`,
`| Plugins now detected as V2 (marked by bot PR) | ${migrated.length} |`,
`| V1-only plugins (removed after ${V1_SUNSET}) | ${v1.length} |`,
`| V1-only plugins tonight's re-check could not confirm (never removed automatically) | ${unconfirmed.length} |`,
`| Plugins with unverified compatibility | ${unknown.length} |`,
`| No push for a year (badge only, not removed) | ${stale.length} |`,
'',
@@ -167,6 +209,9 @@ function report(entries, health, state) {
'### Renamed or transferred',
rows(renamed, e => `- ${link(e)} → ${health.get(e.file).renamedTo}`),
'',
'### Migrated to V2',
rows(migrated, e => `- ${link(e)}: ${e.data.compat} → v2 (${compat.get(e.file).evidence.join('; ')})`),
'',
'### Plugins with unverified compatibility',
'The V2 check could not decide. Verify by hand and set `compat`, or add `npm`/`path` so the check can find the package.',
'',
@@ -174,7 +219,7 @@ function report(entries, health, state) {
'',
`<details><summary>V1-only plugins (${v1.length}), removed after ${V1_SUNSET} unless migrated</summary>`,
'',
rows(v1, e => `- ${link(e)}`),
rows(v1, e => `- ${link(e)}${unconfirmed.includes(e) ? ': re-check inconclusive, verify by hand before the sunset' : ''}`),
'',
'</details>',
'',
@@ -194,12 +239,73 @@ function report(entries, health, state) {
].join('\n');
}
function sunsetIssueBody(entries, migrated) {
const plugins = entries.filter(e => e.category === 'plugins').sort((a, b) => a.data.name.localeCompare(b.data.name));
const v1 = plugins.filter(e => e.data.compat === 'v1' && !migrated.includes(e));
const unknown = plugins.filter(e => e.data.compat === 'unknown' && !migrated.includes(e));
const pending = plugins.filter(e => migrated.includes(e));
const row = e => `| [\`${e.data.name.replace(/[|`]/g, '')}\`](${e.data.repo}) | \`${e.file}\` |`;
const table = (summary, list) => list.length ? [
`<details><summary>${summary}</summary>`, '', '| Plugin | Entry |', '| :--- | :--- |', ...list.map(row), '', '</details>', ''
] : [];
return [
`The catalog only accepts new plugins built for **OpenCode V2**, and existing V1-only plugins carry a \`V1 only\` badge. **On ${V1_SUNSET}, V1-only plugins are removed** from the catalog unless they have migrated ([policy](${POLICY})).`,
'',
'**If you maintain one of these plugins:**',
'1. Depend on `@opencode/plugin` (instead of `@opencode-ai/plugin` 1.x).',
'2. Export `Plugin.define({ id: "your-plugin", async setup(ctx) { … } })`, or a plain `{ id, setup }` object. Keeping the V1 export alongside it for dual support is fine.',
'3. Publish. The nightly health check re-detects compatibility and opens a PR marking your entry `compat: v2`; you can also open that PR yourself and the `validate` check will confirm it.',
'',
'Detection reads your npm package, `package.json` files and source ([`scripts/utils/compat.js`](https://github.com/Heretek-AI/awesome-opencode/blob/main/scripts/utils/compat.js)). If we got yours wrong, comment here with the file that shows V2 support and we will fix the entry.',
'',
`_List updated ${today()} by the nightly health check._`,
'',
...table(`<b>V1-only plugins (${v1.length})</b>`, v1),
...table(`<b>Detected as V2, catalog update pending (${pending.length})</b>: merged with the next <code>bot/health-compat</code> PR`, pending),
...table(`<b>Could not be verified automatically (${unknown.length})</b>: maintainers will check these by hand`, unknown)
].join('\n').trimEnd();
}
/**
* Keep the V1 sunset tracking issue's lists current, post one reminder REMINDER_DAYS before the sunset,
* and close the issue once no V1-only plugins remain after it.
*/
async function updateSunsetIssue(entries, migrated) {
const { data } = await rest(`/repos/${REPO}/issues?state=open&labels=v1-sunset&per_page=10`);
const issue = Array.isArray(data) ? data.find(i => !i.pull_request) : null;
if (!issue) return;
const body = sunsetIssueBody(entries, migrated);
if (body !== issue.body) await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { body } });
const remaining = entries.filter(e => e.category === 'plugins' && e.data.compat === 'v1' && !migrated.includes(e)).length;
const days = daysUntil(V1_SUNSET);
if (days > 0 && days <= REMINDER_DAYS && remaining) {
const comments = await listComments(issue.number);
if (!comments.some(c => (c.body || '').startsWith(REMINDER_MARKER))) {
await comment(issue.number, [
REMINDER_MARKER,
`Reminder: V1-only plugins are removed from the catalog on ${V1_SUNSET}, ${days} day(s) from now. ${remaining} plugin(s) in the list above still use the V1 API.`,
'',
'If yours has migrated, nothing else is needed: the nightly check detects it and opens a PR marking the entry `compat: v2`. If the check got it wrong, reply here with the file that shows V2 support.'
].join('\n'));
console.log(`Posted the sunset reminder on #${issue.number}`);
}
}
if (days <= 0 && !remaining) {
await comment(issue.number, 'No V1-only plugins remain in the catalog. Closing.');
await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { state: 'closed', state_reason: 'completed' } });
}
console.log(`Updated the V1 sunset issue #${issue.number}`);
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const { entries, errors } = loadCatalog();
if (errors.length) throw new Error(`catalog has unparseable files: ${errors.map(e => e.file).join(', ')}`);
const health = await collect(entries);
const compat = await recheckCompat(entries);
const migrated = migratedPlugins(entries, compat);
const issue = await findIssue();
const state = updateState(readState(issue), entries, health);
@@ -213,25 +319,37 @@ async function main() {
console.log(`Wrote ${opts.out}`);
}
const body = report(entries, health, state);
const body = report(entries, health, state, compat);
if (opts.issue && !opts.dryRun) {
await ensureLabels(['health']);
if (issue) await rest(`/repos/${REPO}/issues/${issue.number}`, { method: 'PATCH', body: { body } });
else await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: ISSUE_TITLE, body, labels: ['health'] } });
console.log(`${issue ? 'Updated' : 'Created'} the "${ISSUE_TITLE}" issue`);
await updateSunsetIssue(entries, migrated);
} else if (opts.dryRun) {
console.log(body);
}
// A file that is both renamed and migrated gets the same content in both PRs, so they never conflict
const updated = e => dumpEntry({
...e.data,
...(health.get(e.file).renamedTo ? { repo: health.get(e.file).renamedTo } : {}),
...(migrated.includes(e) ? { compat: 'v2' } : {})
});
const renames = { files: {}, lines: [] };
for (const e of entries) {
const to = health.get(e.file).renamedTo;
if (!to) continue;
renames.files[e.file] = dumpEntry({ ...e.data, repo: to });
renames.files[e.file] = updated(e);
renames.lines.push(`- \`${e.file}\`: ${e.data.repo} → ${to}`);
}
const migrations = { files: {}, lines: [] };
for (const e of migrated) {
migrations.files[e.file] = updated(e);
migrations.lines.push(`- \`${e.file}\` ([${e.data.name}](${e.data.repo})): ${e.data.compat} → v2 (${compat.get(e.file).evidence.join('; ')})`);
}
const removals = { files: {}, lines: [] };
for (const { entry, reason } of removalCandidates(entries, health, state)) {
for (const { entry, reason } of removalCandidates(entries, state, compat)) {
removals.files[entry.file] = null;
removals.lines.push(`- \`${entry.file}\` ([${entry.data.name}](${entry.data.repo})): ${reason}`);
}
@@ -242,6 +360,11 @@ async function main() {
summary.push(...renames.lines);
console.log(`Applied ${renames.lines.length} rename(s)`);
}
if (opts.applyCompat) {
for (const [file, text] of Object.entries(migrations.files)) fs.writeFileSync(path.join(ROOT, file), text);
summary.push(...migrations.lines);
console.log(`Applied ${migrations.lines.length} V2 migration(s)`);
}
if (opts.applyRemovals) {
for (const file of Object.keys(removals.files)) fs.unlinkSync(path.join(ROOT, file));
summary.push(...removals.lines);
@@ -261,6 +384,17 @@ async function main() {
});
if (pr) console.log(`Renames: #${pr.number}`);
}
if (migrations.lines.length) {
const pr = await openPullRequest({
branch: 'bot/health-compat',
files: migrations.files,
title: 'chore: mark plugins that migrated to OpenCode V2',
body: ['The nightly health check now detects OpenCode V2 support in these V1-only or unverified plugins, so they no longer face the V1 sunset.', '', ...migrations.lines].join('\n'),
labels: ['health'],
dryRun: opts.dryRun
});
if (pr) console.log(`V2 migrations: #${Number(pr.number)}`);
}
if (removals.lines.length) {
const pr = await openPullRequest({
branch: 'bot/health-removals',
@@ -277,9 +411,14 @@ async function main() {
const counts = {};
for (const h of health.values()) counts[h.status] = (counts[h.status] || 0) + 1;
console.log('status counts:', JSON.stringify(counts));
console.log(`compat re-check: ${compat.size} plugin(s), ${migrated.length} now V2`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});
if (require.main === module) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}
module.exports = { migratedPlugins, removalCandidates, sunsetIssueBody };
+41
View File
@@ -0,0 +1,41 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { V1_SUNSET } = require('../scripts/utils/catalog');
const { migratedPlugins, removalCandidates, sunsetIssueBody } = require('../scripts/health-check');
const plugin = (id, compat) => ({
category: 'plugins',
id,
file: `data/plugins/${id}.yaml`,
data: { name: id, repo: `https://github.com/someone/${id}`, tagline: 't', description: 'd', kind: 'plugin', topics: ['workflow'], compat }
});
const recheck = results => new Map(Object.entries(results).map(([id, compat]) => [`data/plugins/${id}.yaml`, { compat, evidence: [`probe: ${compat}`] }]));
test('after the sunset, only V1 plugins the re-check still detects as V1 are removed', () => {
const entries = ['still-v1', 'migrated', 'inconclusive', 'unprobed'].map(id => plugin(id, 'v1'));
const compat = recheck({ 'still-v1': 'v1', migrated: 'v2', inconclusive: 'unknown' });
const removed = removalCandidates(entries, {}, compat, V1_SUNSET).map(r => r.entry.id);
assert.deepEqual(removed, ['still-v1']);
assert.deepEqual(removalCandidates(entries, {}, compat, '2026-12-31'), []);
});
test('dead entries are removed after the grace period regardless of compat', () => {
const entries = [plugin('gone', 'v2')];
const removed = removalCandidates(entries, { 'data/plugins/gone.yaml': { since: '2020-01-01', reason: 'archived' } }, new Map());
assert.equal(removed.length, 1);
});
test('plugins not marked v2 that now probe as V2 are migrations', () => {
const entries = [plugin('a', 'v1'), plugin('b', 'unknown'), plugin('c', 'v1'), plugin('d', 'v2')];
const compat = recheck({ a: 'v2', b: 'v2', c: 'unknown' });
assert.deepEqual(migratedPlugins(entries, compat).map(e => e.id), ['a', 'b']);
});
test('the sunset issue lists remaining V1 plugins and pending migrations separately', () => {
const entries = [plugin('stuck', 'v1'), plugin('moved', 'v1'), plugin('odd', 'unknown')];
const body = sunsetIssueBody(entries, [entries[1]]);
assert.match(body, /V1-only plugins \(1\)[\s\S]*stuck\.yaml/);
assert.match(body, /catalog update pending \(1\)[\s\S]*moved\.yaml/);
assert.match(body, /Could not be verified automatically \(1\)[\s\S]*odd\.yaml/);
assert.doesNotMatch(body.split('catalog update pending')[0], /moved\.yaml/);
});