Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/sponsors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: List Sponsors

on:
# Allow manual trigger
workflow_dispatch:

permissions:
contents: read

jobs:
list-sponsors:
permissions:
contents: read
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v6
with:
check-latest: true
node-version: "24"

- name: Install dependencies
run: npm i --ignore-scripts

- name: List sponsors
env:
GITHUB_TOKEN: ${{ secrets.SPONSOR_TOKEN }}
run: node index.js sponsors --org fastify
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,4 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vscode/
sponsors.json
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@ For the fastify organization, the command would look like:
node --env-file=.env index.js emeritus --monthsInactiveThreshold 24
```

### List sponsors

This command reads the organization's sponsors from both GitHub Sponsors and
Open Collective and lists the **recurring tier sponsors** — those whose normalized
monthly contribution reaches at least the lowest tier. Each sponsor is tagged with
its tier (the highest tier its monthly amount meets, rounding down) and the combined
list is ordered by monthly contribution descending. Recurring sponsors that stopped
paying (cancelled or overdue) are flagged as `lapsed`.

The default tiers are tier 1 = $5/mo, tier 2 = $50/mo, tier 3 = $100/mo and
tier 4 = $300/mo (configurable in `commands/sponsors.js`). One-time payments and
sub-tier contributions are excluded. The result is logged and written to a
`sponsors.json` file with three keys: `tiers` (the tier definitions), `sponsors`
(the combined tier list) and `flagged` (the lapsed tier sponsors).

```bash
node --env-file=.env index.js sponsors --org <org>
```

For the fastify organization, the command would look like:

```bash
node --env-file=.env index.js sponsors
```

Reading Open Collective backers is public and needs no token. Open Collective
exposes per-charge data, so a lapsed contribution shows its `lastChargedAt` /
`nextChargeDate`. GitHub does not expose individual charges, so a GitHub sponsor
is only flagged as `lapsed` when a recurring sponsorship has been cancelled
(its `lastChargedAt` is always `null`). To raise the Open Collective rate limit
you may optionally set `OC_PERSONAL_TOKEN` in `.env` (a personal token from your
own account — Open Collective has no org-level token).

## License

Licensed under [MIT](./LICENSE).
105 changes: 105 additions & 0 deletions commands/sponsors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { writeFileSync } from 'node:fs'

const OUTPUT_FILE = './sponsors.json'

/**
* Fastify's sponsorship tiers, defined by their minimum monthly contribution in
* dollars. A sponsor is assigned the highest tier whose threshold its monthly
* contribution meets (amounts between tiers round down). Sponsors below tier 1
* are not considered tier sponsors.
*/
const TIERS = [
{ level: 1, monthly: 5 },
{ level: 2, monthly: 50 },
{ level: 3, monthly: 100 },
{ level: 4, monthly: 300 },
]

/**
* Fetches all sponsors of an organization from GitHub Sponsors and Open Collective,
* keeps only the recurring tier sponsors (monthly contribution within a tier),
* sorts them by monthly amount descending and flags those that stopped paying.
* The result is logged and written to a JSON file for later inspection.
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies.
* @param {{ org: string }} options - Command options.
* @returns {Promise<void>}
*/
export default async function sponsors ({ client, logger }, { org }) {
logger.info('Running sponsors command for organization: %s', org)

const githubSponsors = await client.getGithubSponsors(org)
logger.info('Fetched %s GitHub sponsorships', githubSponsors.length)

const openCollectiveSponsors = await client.getOpenCollectiveSponsors(org)
logger.info('Fetched %s Open Collective backers', openCollectiveSponsors.length)

// Keep only recurring contributions that reach at least tier 1, tag each with
// its tier, and order the combined list by monthly contribution descending.
const sponsorList = [...githubSponsors, ...openCollectiveSponsors]
.filter((sponsor) => tierFor(sponsor.monthlyAmount) !== null)
.map((sponsor) => ({ ...sponsor, tierLevel: tierFor(sponsor.monthlyAmount) }))
.sort((a, b) => b.monthlyAmount - a.monthlyAmount)

logger.info('Tier sponsors (%s), ordered by monthly amount:', sponsorList.length)
for (const sponsor of sponsorList) {
logger.info(
'- tier %s · $%s/mo · %s [%s]%s',
sponsor.tierLevel,
sponsor.monthlyAmount,
displayName(sponsor),
sponsor.source,
sponsor.lapsed ? ' · ⚠ lapsed' : ''
)
}

const flagged = sponsorList.filter((sponsor) => sponsor.lapsed)
if (flagged.length > 0) {
logger.warn('%s lapsed tier sponsor(s) need attention:', flagged.length)
for (const sponsor of flagged) {
logger.warn(
' ⚠ tier %s · %s [%s] — last charged %s',
sponsor.tierLevel,
displayName(sponsor),
sponsor.source,
sponsor.lastChargedAt ?? 'unknown'
)
}
}

const result = {
tiers: TIERS,
sponsors: sponsorList,
flagged,
}

writeFileSync(OUTPUT_FILE, JSON.stringify(result, null, 2))
logger.info('Sponsors written to %s', OUTPUT_FILE)
}

/**
* Returns the tier level for a given normalized monthly contribution, or null when
* the contribution is missing or below the lowest tier.
* @param {number|null} monthlyAmount - The normalized monthly contribution in dollars.
* @returns {number|null} The tier level (1-4) or null.
*/
function tierFor (monthlyAmount) {
if (monthlyAmount === null || monthlyAmount < TIERS[0].monthly) {
return null
}
let level = null
for (const tier of TIERS) {
if (monthlyAmount >= tier.monthly) {
level = tier.level
}
}
return level
}

/**
* Builds a human-friendly label for a sponsor.
* @param {import('../github-api.js').Sponsor} sponsor - The sponsor.
* @returns {string} The display label.
*/
function displayName (sponsor) {
return sponsor.name ? `${sponsor.login} (${sponsor.name})` : sponsor.login
}
Loading