diff --git a/.github/workflows/project-status.yml b/.github/workflows/project-status.yml new file mode 100644 index 0000000..6f8e1fc --- /dev/null +++ b/.github/workflows/project-status.yml @@ -0,0 +1,157 @@ +name: Project status + +# Regenerates one account's status badges, STATUS.md and profile section from +# its own status.json, then reports what has drifted from reality. +# +# Each account calls this from its own hub, so a privacykey badge is served +# from a privacykey repo and no credential reaches across identities. +# +# Caller: +# jobs: +# status: +# uses: privacykey/gh-workflows/.github/workflows/project-status.yml@v1 +# secrets: +# status-token: ${{ secrets.STATUS_TOKEN }} + +on: + workflow_call: + inputs: + aggregate: + description: >- + Comma-separated hubs whose listed repos also appear in this hub's + rendered section, e.g. "privacykey/.github,adamXbot/.github". Read + over plain HTTPS with no token — every hub is public and already + withholds its private repos. + type: string + required: false + default: '' + target: + description: 'Markdown file carrying the STATUS markers.' + type: string + required: false + default: 'README.md' + collapsed: + description: >- + Render the section inside a
block. True on a personal + profile, where this is one section among many; false on an org + profile, where the project list is the page. + type: boolean + required: false + default: true + drift: + description: 'Check claimed tiers against reality and open a report PR.' + type: boolean + required: false + default: true + secrets: + status-token: + description: >- + Read-only token for THIS account only. Needed to see private repos; + without it the drift check is skipped and says so. Deliberately not + cross-account — a leak should cost one identity, not three. + required: false + +permissions: + contents: write + pull-requests: write + +jobs: + build: + name: Regenerate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check out the shared status action + uses: actions/checkout@v4 + with: + repository: privacykey/gh-workflows + ref: v1 + path: .status-action + sparse-checkout: actions/project-status + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + # Fails on a malformed status.json before writing anything, so a bad edit + # can never leave the hub half-regenerated. + - name: Build + run: | + node .status-action/actions/project-status/build.mjs \ + --hub "$GITHUB_WORKSPACE" \ + --target "${{ inputs.target }}" \ + --collapsed "${{ inputs.collapsed }}" \ + ${{ inputs.aggregate && format('--aggregate ''{0}''', inputs.aggregate) || '' }} + + - name: Commit if anything changed + run: | + rm -rf .status-action + if [ -z "$(git status --porcelain)" ]; then + echo "Nothing to commit." + exit 0 + fi + git config --local user.email "actions@noreply.github.com" + git config --local user.name "github-actions[bot]" + git add badges STATUS.md "${{ inputs.target }}" + git commit -m ":clipboard: Regenerate project status" + git push + + drift: + name: Check against reality + runs-on: ubuntu-latest + needs: build + if: inputs.drift && github.event_name != 'push' + steps: + - uses: actions/checkout@v4 + + - name: Check out the shared status action + uses: actions/checkout@v4 + with: + repository: privacykey/gh-workflows + ref: v1 + path: .status-action + sparse-checkout: actions/project-status + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Derive findings + id: run + env: + STATUS_TOKEN: ${{ secrets.status-token }} + run: | + if [ -z "$STATUS_TOKEN" ]; then + echo "::warning::status-token not set — private repos can't be read, so drift is skipped." + exit 0 + fi + node .status-action/actions/project-status/drift.mjs --hub "$GITHUB_WORKSPACE" > /tmp/report.md + cat /tmp/report.md >> "$GITHUB_STEP_SUMMARY" + if grep -qE 'thing\(s\) to fix|second look' /tmp/report.md; then + echo "found=true" >> "$GITHUB_OUTPUT" + fi + + # One PR, force-updated in place. Skipping a week costs nothing — the next + # run rewrites it to current reality instead of stacking up a backlog. + - name: Open or update the report PR + if: steps.run.outputs.found == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH=chore/status-report + rm -rf .status-action + git config --local user.email "actions@noreply.github.com" + git config --local user.name "github-actions[bot]" + git checkout -B "$BRANCH" + mkdir -p .github/status + cp /tmp/report.md .github/status/report.md + git add .github/status/report.md + git commit -m ":clipboard: Status report" + git push -f origin "$BRANCH" + if gh pr view "$BRANCH" --json number >/dev/null 2>&1; then + gh pr edit "$BRANCH" --body-file /tmp/report.md + else + gh pr create --base "${{ github.event.repository.default_branch }}" --head "$BRANCH" \ + --title "Project status — things to fix" --body-file /tmp/report.md + fi diff --git a/actions/project-status/build.mjs b/actions/project-status/build.mjs new file mode 100644 index 0000000..7c5442c --- /dev/null +++ b/actions/project-status/build.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// Regenerates one account's status artefacts from its own status.json: +// badges/.json shields /endpoint payloads (public + listed only) +// STATUS.md the uncollapsed link target every badge points at +// .md the collapsed section, between the STATUS markers +// +// Each account owns its own hub, so a privacykey badge is served from a +// privacykey repo and no credential ever has to reach across identities. +// Tier definitions live here rather than in each hub, so three copies of the +// same promise can't drift apart. +// +// node build.mjs --hub write +// node build.mjs --hub --check verify only +// node build.mjs --hub --aggregate a/b,c/d also pull in other hubs +// +// --aggregate fetches other hubs' status.json over plain HTTPS. It needs no +// token: every hub lives in a public repo and each already withholds its +// private repos, so aggregating cannot surface anything not already published. + +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const arg = (name) => { + const i = process.argv.indexOf(name) + return i === -1 ? null : process.argv[i + 1] +} +const HUB = arg('--hub') ?? process.cwd() +const CHECK = process.argv.includes('--check') +const AGGREGATE = (arg('--aggregate') ?? '').split(',').map((s) => s.trim()).filter(Boolean) +const TARGET = arg('--target') ?? 'README.md' +// Collapsed on a personal profile, where this is one section among many. +// Expanded on an org profile, where the project list IS the page. +const COLLAPSED = arg('--collapsed') !== 'false' + +const die = (m) => { console.error(`✗ ${m}`); process.exit(1) } + +const { tiers, groups } = JSON.parse(readFileSync(join(HERE, 'tiers.json'), 'utf8')) +const hub = JSON.parse(readFileSync(join(HUB, 'status.json'), 'utf8')) + +if (!/^\d{4}-\d{2}-\d{2}$/.test(hub.reviewed ?? '')) die('status.json: "reviewed" must be YYYY-MM-DD') +if (!hub.owner) die('status.json: "owner" is required') + +const DISTRIBUTIONS = ['open-source', 'appstore-closed', 'public-pending', 'private'] + +const load = (owner, repos, source) => { + const out = [] + for (const [repo, r] of Object.entries(repos)) { + const full = `${owner}/${repo}` + if (!tiers[r.tier]) die(`${full}: unknown tier "${r.tier}"`) + if (!groups[r.group]) die(`${full}: unknown group "${r.group}"`) + if (!DISTRIBUTIONS.includes(r.distribution)) die(`${full}: bad distribution "${r.distribution}"`) + if (typeof r.public !== 'boolean' || typeof r.listed !== 'boolean') die(`${full}: "public" and "listed" must be booleans`) + // The clause that stops an unreleased product reaching a public page. + if (r.listed && !r.public) die(`${full}: listed:true but public:false`) + if (r.tier === 'Fork' && !r.canonical) die(`${full}: Fork requires "canonical"`) + out.push({ owner, repo, full, source, ...r }) + } + return out +} + +const own = load(hub.owner, hub.repos, null) + +// Other hubs contribute their listed repos to the rendered view only. Their +// badges stay theirs — nothing here writes into another account's namespace. +const foreign = [] +const RAW = process.env.STATUS_RAW_BASE ?? 'https://raw.githubusercontent.com' +for (const ref of AGGREGATE) { + const url = `${RAW}/${ref}/main/status.json` + const res = await fetch(url) + if (!res.ok) die(`--aggregate ${ref}: ${url} returned HTTP ${res.status}`) + const other = await res.json() + foreign.push(...load(other.owner, other.repos, ref).filter((e) => e.listed)) +} + +const byTier = (a, b) => tiers[a.tier].order - tiers[b.tier].order || a.repo.localeCompare(b.repo) +const ownListed = own.filter((e) => e.listed) +const allListed = [...ownListed, ...foreign] + +// Badges for this account's public, listed repos only. The badges directory is +// browsable, so one file per repo would publish an index of unreleased work. +const outputs = new Map() +for (const e of ownListed) { + outputs.set(`badges/${e.repo}.json`, JSON.stringify({ + schemaVersion: 1, label: 'status', message: e.tier, + color: tiers[e.tier].color, style: 'flat', cacheSeconds: 3600, + }, null, 2) + '\n') +} + +// ---------------------------------------------------------------- STATUS.md +// Repo names are unique within an account, so a bare `#` anchor is +// unambiguous here — the collision risk that forced owner-qualified anchors +// under a single combined hub doesn't exist once each account owns its own. +let md = `# Project status\n\n` + + `What I promise for each ${hub.owner} project, and what I don't. Every repo's\n` + + `status badge links here.\n\n` + + `**Tiers last reviewed: ${hub.reviewed}.** Set by hand — it's when I last actually\n` + + `looked, not when a script last ran.\n\n` + + `| Tier | What it means |\n| --- | --- |\n` + + Object.entries(tiers).sort((a, b) => a[1].order - b[1].order) + .filter(([n]) => own.some((e) => e.tier === n)) + .map(([n, t]) => `| ${t.emoji} **${n}** | ${t.promise} |`).join('\n') + + `\n\nPrivate and pre-announcement projects aren't listed here.\n\n---\n\n` + +for (const [gk, g] of Object.entries(groups).sort((a, b) => a[1].order - b[1].order)) { + const inGroup = ownListed.filter((e) => e.group === gk).sort(byTier) + if (!inGroup.length) continue + md += `## ${g.title}\n\n` + for (const e of inGroup) { + const t = tiers[e.tier] + md += `### ${e.repo}\n\n${t.emoji} **${e.tier}** — ${t.oneLiner}\n\n${t.promise}\n\n` + if (e.distribution === 'appstore-closed') md += `_Ships on the App Store; source is closed._\n\n` + if (e.submissions) md += `_Submissions are ${e.submissions}._\n\n` + if (e.seekingMaintainer) md += `**Open to a new maintainer** — get in touch.\n\n` + if (e.note) md += `${e.note}\n\n` + md += `\n\n` + } +} +outputs.set('STATUS.md', md.trimEnd() + '\n') + +// ---------------------------------------------------------------- rendered region +// No GitHub alerts in here — they don't render inside a
block. +const scope = AGGREGATE.length ? 'every project I maintain' : `every ${hub.owner} project` +let region = COLLAPSED + ? `
\n 📋 Project status — what I promise for ${scope} (reviewed ${hub.reviewed})\n

\n\n` + : `## Projects\n\nWhat I promise for ${scope}, and what I don't. Reviewed ${hub.reviewed}.\n\n` +region += `Each badge links to the full promise. Private and pre-announcement projects aren't listed.\n\n` + + `| | Tier | What it means |\n| - | - | - |\n` + + Object.entries(tiers).sort((a, b) => a[1].order - b[1].order) + .filter(([n]) => allListed.some((e) => e.tier === n)) + .map(([n, t]) => `| ${t.emoji} | **${n}** | ${t.oneLiner} |`).join('\n') + + `\n\n` + +for (const [gk, g] of Object.entries(groups).sort((a, b) => a[1].order - b[1].order)) { + const inGroup = allListed.filter((e) => e.group === gk).sort(byTier) + if (!inGroup.length) continue + region += `**${g.title}**\n\n| Project | Status | |\n| - | - | - |\n` + for (const e of inGroup) { + const extra = [ + e.distribution === 'appstore-closed' ? 'App Store, source closed' : '', + e.seekingMaintainer ? 'maintainer wanted' : '', + e.submissions === 'open' ? 'submissions open' : '', + ].filter(Boolean).join(' · ') + region += `| [${e.repo}](https://github.com/${e.full}) | ${tiers[e.tier].emoji} ${e.tier} | ${extra} |\n` + } + region += `\n` +} +region += COLLAPSED ? `

\n
` : `See [STATUS.md](STATUS.md) for what each tier promises.` + +const START = '' +const END = '' +const targetPath = join(HUB, TARGET) +if (!existsSync(targetPath)) die(`${TARGET} not found in ${HUB}`) +const doc = readFileSync(targetPath, 'utf8') +if (!doc.includes(START) || !doc.includes(END)) die(`${TARGET} is missing the ${START} / ${END} markers`) +outputs.set(TARGET, doc.slice(0, doc.indexOf(START) + START.length) + `\n${region}\n` + doc.slice(doc.indexOf(END))) + +// ---------------------------------------------------------------- write / check +if (CHECK) { + const stale = [...outputs].filter(([p, want]) => { + const abs = join(HUB, p) + return !existsSync(abs) || readFileSync(abs, 'utf8') !== want + }).map(([p]) => p) + if (stale.length) { + console.error(`✗ ${stale.length} generated file(s) stale or hand-edited:`) + stale.forEach((p) => console.error(` ${p}`)) + process.exit(1) + } + console.log(`✓ ${hub.owner}: all ${outputs.size} generated files match status.json`) + process.exit(0) +} + +// Rebuilt from scratch so a repo going private drops its badge rather than +// leaving a stale public one behind. +const badgeDir = join(HUB, 'badges') +if (existsSync(badgeDir)) rmSync(badgeDir, { recursive: true }) +for (const [p, content] of outputs) { + const abs = join(HUB, p) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content) +} + +const counts = {} +for (const e of own) counts[e.tier] = (counts[e.tier] ?? 0) + 1 +console.log(`✓ ${hub.owner}: ${own.length} repos, ${ownListed.length} listed` + + (foreign.length ? `, +${foreign.length} aggregated` : '')) +console.log(` ${Object.entries(counts).sort((a, b) => tiers[a[0]].order - tiers[b[0]].order) + .map(([t, n]) => `${tiers[t].emoji} ${t} ${n}`).join(' ')}`) diff --git a/actions/project-status/drift.mjs b/actions/project-status/drift.mjs new file mode 100644 index 0000000..03d94f0 --- /dev/null +++ b/actions/project-status/drift.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +// Weekly reality check against status.json. +// +// It reports FACTS, not tiers. An earlier version re-derived each repo's whole +// tier from activity signals and flagged 28 of 61 repos — nearly all of them +// false, because the signals can't tell a billing block from an abandoned repo, +// or a reusable workflow from a broken one. A weekly PR full of noise gets +// ignored, and an ignored check is worse than no check. +// +// So this only reports things that are objectively true and worth acting on: +// a URL that doesn't load, a tap serving a stale version, a queue nobody +// answered. Tier suggestions are limited to the two unambiguous directions. +// +// Needs a token that can read all three owners: STATUS_TOKEN in the environment. + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const argOf = (n) => { const i = process.argv.indexOf(n); return i === -1 ? null : process.argv[i + 1] } +const HUB = argOf('--hub') ?? process.cwd() +const TOKEN = process.env.STATUS_TOKEN || process.env.GITHUB_TOKEN +const NOW = Date.now() +const DAY = 86_400_000 +const BOT = /\[bot\]$/ +const SWEEP = /adopt shared renovate preset|single-source toolchain|migrate release to reusable|strip internal authoring|sha-pin/i + +const data = JSON.parse(readFileSync(join(HUB, 'status.json'), 'utf8')) +const OWNER = data.owner + +const api = async (path) => { + const res = await fetch(`https://api.github.com/${path}`, { + headers: { accept: 'application/vnd.github+json', ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}) }, + }) + if (!res.ok) return { status: res.status, body: null } + return { status: res.status, body: await res.json() } +} + +const probe = async (url) => { + try { + const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(12_000) }) + return res.status + } catch { return 0 } +} + +const findings = [] // { repo, kind, detail } +const suggests = [] // { repo, claimed, suggest, why } +const pinned = [] +const unread = [] +const add = (repo, kind, detail) => findings.push({ repo, kind, detail }) + +// ---------------------------------------------------------------- tap index +// Which cask/formula serves which repo, and at what version. A tap that has +// fallen behind its own repo's latest release is the one breakage that silently +// ships an old build to everyone who installs it. +const taps = new Map() +for (const tap of [...new Set([`${data.owner}/homebrew-tap`, 'privacykey/homebrew-tap'])]) { + for (const dir of ['Casks', 'Formula']) { + const list = await api(`repos/${tap}/contents/${dir}`) + for (const f of list.body ?? []) { + if (!f.name.endsWith('.rb')) continue + const file = await api(`repos/${tap}/contents/${dir}/${f.name}`) + if (!file.body?.content) continue + const src = Buffer.from(file.body.content, 'base64').toString('utf8') + const version = src.match(/^\s*version\s+"([^"]+)"/m)?.[1] + const target = src.match(/github\.com\/([\w.-]+\/[\w.-]+)\/releases/i)?.[1] + if (version && target) taps.set(target.toLowerCase(), { tap, file: `${dir}/${f.name}`, version }) + } + } +} + +// ---------------------------------------------------------------- per repo +{ + const owner = OWNER + for (const [repo, r] of Object.entries(data.repos)) { + const full = `${owner}/${repo}` + if (r.pin) { pinned.push({ full, tier: r.tier, ...r.pin }); continue } + if (r.tier === 'Archived' || r.tier === 'Fork') continue + if (repo === '.github' && r.tier === 'Maintained') continue // this hub grades everything but itself + if (full === 'AdamXweb/AdamXweb') continue + + const meta = await api(`repos/${full}`) + if (!meta.body) { unread.push(`${full} — HTTP ${meta.status}`); continue } + + // --- hard mismatches against GitHub's own flags ------------------------ + if (meta.body.archived && r.tier !== 'Archived') suggests.push({ repo: full, claimed: r.tier, suggest: 'Archived', why: 'GitHub reports this repo as archived' }) + if (meta.body.fork && r.tier !== 'Fork') suggests.push({ repo: full, claimed: r.tier, suggest: 'Fork', why: 'GitHub reports this repo as a fork' }) + if (meta.body.private === r.public) add(full, 'visibility', `status.json says public: ${r.public}, GitHub says private: ${meta.body.private}`) + + // --- empty repo / no README ------------------------------------------- + if (meta.body.size === 0) { add(full, 'empty', 'the repository has no commits at all'); continue } + const readme = await api(`repos/${full}/readme`) + if (!readme.body || readme.body.size < 200) { + add(full, 'no readme', readme.body ? `README is only ${readme.body.size} bytes` : 'no README at all') + } + + // --- advertised URLs --------------------------------------------------- + if (meta.body.homepage) { + const code = await probe(meta.body.homepage) + if (code === 0 || code >= 400) { + add(full, 'dead link', `homepage ${meta.body.homepage} → ${code === 0 ? 'did not resolve' : `HTTP ${code}`}`) + } + } + + // --- releases ---------------------------------------------------------- + const rels = await api(`repos/${full}/releases?per_page=20`) + const all = rels.body ?? [] + const published = all.filter((x) => !x.draft && !x.prerelease) + const latest = published[0] + if (!published.length && all.some((x) => x.draft)) { + add(full, 'draft release', 'the only release is a draft — invisible to everyone, and it creates no tag') + } + + // --- tap freshness ----------------------------------------------------- + const tapped = taps.get(full.toLowerCase()) + if (tapped && latest) { + // Tags aren't uniform across the portfolio: v0.1.2, cli-v0.1.6, 0.4.0. + // Compare the version part only, or every prefixed tag reads as stale. + const tag = latest.tag_name.match(/(\d[\d.]*)$/)?.[1] ?? latest.tag_name + if (tapped.version !== tag) { + const age = Math.round((NOW - Date.parse(latest.published_at)) / DAY) + add(full, 'stale tap', `${tapped.tap} ${tapped.file} serves ${tapped.version}, but ${latest.tag_name} shipped ${age} days ago`) + } + } + + // --- workflow health --------------------------------------------------- + // startup_failure is a platform/billing state, not a repo state — Actions + // billing is currently blocked on two of the three owners. And a workflow + // that only has `workflow_call` triggers always fails when run directly, + // so it is excluded rather than reported as a fault. + const wfs = await api(`repos/${full}/actions/workflows`) + const callable = new Set() + for (const w of wfs.body?.workflows ?? []) { + const f = await api(`repos/${full}/contents/${w.path}`) + if (!f.body?.content) continue + const src = Buffer.from(f.body.content, 'base64').toString('utf8') + if (/workflow_call:/.test(src) && !/\bpush:|\bpull_request:|\bschedule:/.test(src)) callable.add(w.id) + } + const runs = await api(`repos/${full}/actions/runs?branch=${meta.body.default_branch}&per_page=30`) + const real = (runs.body?.workflow_runs ?? []).filter( + (x) => x.conclusion && x.conclusion !== 'startup_failure' && !callable.has(x.workflow_id), + ) + if (real.length >= 3 && !real.some((x) => x.conclusion === 'success')) { + add(full, 'ci failing', `the last ${real.length} completed runs on ${meta.body.default_branch} all failed`) + } + + // --- an ignored queue -------------------------------------------------- + // Skipped for Reference, where the queue is a submissions inbox rather than + // a bug tracker, and a slow one says nothing about whether the list works. + if (r.tier !== 'Reference') { + const issues = await api(`repos/${full}/issues?state=open&sort=created&direction=asc&per_page=100`) + const stale = (issues.body ?? []).filter( + (i) => !i.pull_request && i.user?.login !== owner && i.user?.login !== 'AdamXweb' + && !BOT.test(i.user?.login ?? '') && NOW - Date.parse(i.created_at) > 180 * DAY, + ) + if (stale.length) { + add(full, 'ignored queue', `${stale.length} outside issue(s) unanswered for 180+ days, oldest #${stale[0].number} from ${stale[0].created_at.slice(0, 10)}`) + } + } + + // --- the only two tier suggestions worth making ------------------------ + if (r.tier === 'Active') { + const since = new Date(NOW - 90 * DAY).toISOString() + const cs = await api(`repos/${full}/commits?sha=${meta.body.default_branch}&since=${since}&per_page=100`) + const human = (cs.body ?? []).filter( + (c) => c.parents?.length < 2 && !BOT.test(c.author?.login ?? '') && !SWEEP.test(c.commit.message.split('\n')[0]), + ) + if (!human.length) suggests.push({ repo: full, claimed: 'Active', suggest: 'Maintained or lower', why: 'no human commits on the default branch in 90 days' }) + } + if (r.tier === 'Building' && latest) { + suggests.push({ repo: full, claimed: 'Building', suggest: 'Active or Maintained', why: `it has a published release (${latest.tag_name}) — Building means nothing has shipped` }) + } + } +} + +// ---------------------------------------------------------------- report +const date = new Date(NOW).toISOString().slice(0, 10) +let md = `_${OWNER} — checked ${date}. Tiers last reviewed by hand: ${data.reviewed}._\n\n` + +if (NOW - Date.parse(data.reviewed) > 90 * DAY) { + md += `> **The tiers haven't been reviewed by hand in over 90 days.** This list is only as` + + ` trustworthy as that date.\n\n` +} + +if (!findings.length && !suggests.length) { + md += `Nothing to report. Every advertised link resolves, no queue is being ignored,` + + ` and no repo contradicts its claimed tier.\n\n` +} + +if (findings.length) { + md += `## ${findings.length} thing(s) to fix\n\n| Repo | What | Detail |\n| --- | --- | --- |\n` + for (const f of findings) md += `| [${f.repo}](https://github.com/${f.repo}) | ${f.kind} | ${f.detail} |\n` + md += `\n` +} + +if (suggests.length) { + md += `## ${suggests.length} tier(s) worth a second look\n\n| Repo | Says | Suggests | Why |\n| --- | --- | --- | --- |\n` + for (const s of suggests) md += `| [${s.repo}](https://github.com/${s.repo}) | ${s.claimed} | ${s.suggest} | ${s.why} |\n` + md += `\nNothing has been changed — edit \`status.json\` to accept any of these.\n\n` +} + +if (pinned.length) { + md += `## Pinned — deliberately not re-checked\n\n` + for (const p of pinned) md += `- **${p.full}** (${p.tier}, pinned ${p.reviewed}) — ${p.reason}\n` + md += `\n` +} +if (unread.length) { + md += `## Not checked\n\n${unread.map((s) => `- ${s}`).join('\n')}\n\n` + + `_These were not evaluated at all — usually a token that can't read them, which means` + + ` their absence above is not a clean bill of health._\n` +} + +process.stdout.write(md) diff --git a/actions/project-status/tiers.json b/actions/project-status/tiers.json new file mode 100644 index 0000000..57576bd --- /dev/null +++ b/actions/project-status/tiers.json @@ -0,0 +1,72 @@ +{ + "_comment": "Canonical tier and group definitions, shared by every status hub. Kept here rather than duplicated into each hub's status.json so three copies can't drift apart — a tier's promise is a claim made on ~24 public repos and it has to read the same everywhere.", + + "tiers": { + "Active": { + "emoji": "🟢", + "color": "2ea043", + "order": 1, + "oneLiner": "shipping now; installable today and still gaining capability", + "promise": "I'm in this most weeks. Features are landing, so expect breaking changes between releases — read the changelog before you upgrade. I read issues and generally act on them quickly." + }, + "Building": { + "emoji": "🚧", + "color": "8250df", + "order": 2, + "oneLiner": "under construction; nothing released, nothing promised", + "promise": "Being built. Nothing is released, nothing is supported, and it can change shape or disappear without notice. Don't build on it — watch the repo if you want to know when it ships." + }, + "Maintained": { + "emoji": "🛡️", + "color": "1f6feb", + "order": 3, + "oneLiner": "feature-complete; kept working and patched, not grown", + "promise": "I'm not adding features here. It still gets security patches, dependency bumps and fixes for anything that breaks. Safe to depend on for exactly what it does today." + }, + "Parked": { + "emoji": "📦", + "color": "8b949e", + "order": 4, + "oneLiner": "done or paused; it works, nothing is watching it", + "promise": "Finished or paused. It does what the README says and I left it working, but nothing is watching it, so it could rot quietly. I'll answer a question. I'm not promising a fix or a release." + }, + "Unmaintained": { + "emoji": "⚠️", + "color": "d29922", + "order": 5, + "oneLiner": "I'm not watching this one", + "promise": "I'm not watching this one. Something it advertises may already be broken, and bugs won't be fixed on a schedule. Read it, fork it, vendor it — this says nothing about whether the code is any good." + }, + "Reference": { + "emoji": "📖", + "color": "1b7c83", + "order": 6, + "oneLiner": "content, not software; graded on how current it is", + "promise": "The content was accurate as of the date shown. Links and details go stale." + }, + "Fork": { + "emoji": "🍴", + "color": "6e7681", + "order": 7, + "oneLiner": "someone else's project; the real one is upstream", + "promise": "Not my project and not maintained by me. The canonical version is upstream — go there for issues, releases and support." + }, + "Archived": { + "emoji": "📁", + "color": "6639ba", + "order": 8, + "oneLiner": "read-only; kept for reference", + "promise": "Archived on GitHub and read-only. Kept so links don't rot." + } + }, + + "groups": { + "privacy": { "title": "privacy tools", "order": 1 }, + "apps": { "title": "apps", "order": 2 }, + "tools": { "title": "tools & utilities", "order": 3 }, + "infra": { "title": "shared infrastructure", "order": 4 }, + "sites": { "title": "sites & docs", "order": 5 }, + "content": { "title": "lists & content", "order": 6 }, + "archive": { "title": "forks & archive", "order": 7 } + } +}