Skip to content

Sponsors Refresh

Sponsors Refresh #679

name: Sponsors Refresh
# Hourly job that fetches sponsor data from the GitHub Sponsors API and
# commits the result to the repo. This is the SINGLE source of truth for
# sponsor state — evolve.sh reads the committed files and does not hit
# the API. Decoupling sponsor freshness from the 8h evolution gap means
# SPONSORS.md / README.md / sponsors/*.json stay current even when no
# evolution session runs.
#
# Side effect: refresh_sponsors.py opens shoutout issues for newly-eligible
# sponsors ($10+ tier), which is why this job needs `issues: write` and
# passes a bot GH_TOKEN to the processing step.
on:
schedule:
- cron: '15 * * * *' # hourly, offset 15 minutes from the evolution cron to avoid push races
workflow_dispatch:
concurrency:
group: sponsors-refresh
cancel-in-progress: false
permissions:
contents: write
issues: write
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Generate bot token
id: bot-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.bot-token.outputs.token }}
ref: main
fetch-depth: 1
- name: Detect bot identity
id: bot-info
run: |
set -euo pipefail
SLUG="${{ steps.bot-token.outputs.app-slug }}"
if [ -z "$SLUG" ]; then
echo "::error::GitHub App slug is empty."
exit 1
fi
echo "login=${SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "email=${SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Configure git
run: |
set -euo pipefail
git config user.name "${{ steps.bot-info.outputs.login }}"
git config user.email "${{ steps.bot-info.outputs.email }}"
- name: Fetch sponsor data
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# GH_PAT must have read:user scope. gh writes either a result
# or a {"errors": [...]} body to /tmp/sponsor_raw.json — either
# way refresh_sponsors.py surfaces it loudly via FetchFailed.
# We tolerate a non-zero gh exit here because the error body is
# what the downstream processor needs to see.
gh api graphql -f query='{ viewer { sponsorshipsAsMaintainer(first: 100, activeOnly: true) { totalCount nodes { isOneTimePayment sponsorEntity { ... on User { login } ... on Organization { login } } tier { monthlyPriceInCents isOneTime } } } } }' \
> /tmp/sponsor_raw.json 2>/tmp/sponsor_query_stderr.log || true
if [ -s /tmp/sponsor_query_stderr.log ]; then
echo "WARNING: gh sponsor query stderr:"
sed 's/^/ /' /tmp/sponsor_query_stderr.log
fi
- name: Process and update sponsor files
env:
# Bot token for `gh issue create` (shoutout issues). Needs
# `issues: write`, granted at the job level above.
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
run: |
set -euo pipefail
OUTPUT=$(python3 scripts/refresh_sponsors.py)
echo "→ refresh_sponsors output: $OUTPUT"
- name: Commit and push if changed
env:
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
run: |
set -euo pipefail
git add sponsors/active.json sponsors/sponsor_info.json SPONSORS.md README.md
if git diff --cached --quiet; then
echo "→ No sponsor changes to commit."
exit 0
fi
git commit -m "sponsors: hourly refresh"
# Rebase-on-race retry loop. The evolution workflow pushes to
# main on a separate hourly schedule, so a race is expected.
# We commit first, then loop: on push failure, fetch origin/main,
# rebase our commit onto it, and retry. Abort (loudly) if rebase
# fails — a conflict on auto-generated sponsor files means
# something is seriously wrong and a human should look.
for attempt in 1 2 3 4 5; do
if git push origin HEAD:main; then
echo "→ Push succeeded on attempt $attempt."
exit 0
fi
echo " Push failed (attempt $attempt) — rebasing onto origin/main and retrying..."
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
echo "::error::rebase onto origin/main failed — manual intervention required"
exit 1
fi
done
echo "::error::push failed after 5 attempts"
exit 1