diff --git a/.github/badges/ghcr-downloads.json b/.github/badges/ghcr-downloads.json new file mode 100644 index 00000000..4a51303f --- /dev/null +++ b/.github/badges/ghcr-downloads.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "label": "GHCR pulls", + "message": "0", + "color": "blue", + "cacheSeconds": 3600 +} diff --git a/.github/workflows/badge-ghcr-downloads.yml b/.github/workflows/badge-ghcr-downloads.yml new file mode 100644 index 00000000..ac74bc18 --- /dev/null +++ b/.github/workflows/badge-ghcr-downloads.yml @@ -0,0 +1,54 @@ +name: "Badge: GHCR downloads" + +on: + schedule: + # Update periodically (GitHub schedules may be delayed) + - cron: '17 * * * *' + workflow_dispatch: {} + +permissions: + contents: write + packages: read + +concurrency: + group: ghcr-downloads-badge + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout (main) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: main + + - name: Set up Node + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: 20 + + - name: Update GHCR downloads badge + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_OWNER: ${{ github.repository_owner }} + GHCR_PACKAGE: charon + BADGE_OUTPUT: .github/badges/ghcr-downloads.json + run: node scripts/update-ghcr-downloads-badge.mjs + + - name: Commit and push (if changed) + shell: bash + run: | + set -euo pipefail + + if git diff --quiet; then + echo "No changes." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add .github/badges/ghcr-downloads.json + git commit -m "chore(badges): update GHCR downloads [skip ci]" + git push origin HEAD:main diff --git a/README.md b/README.md index 5b175030..a08afa41 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@

Project Status: Active – The project is being actively developed. Docker Pulls + GHCR Pulls Release
Code Coverage diff --git a/scripts/update-ghcr-downloads-badge.mjs b/scripts/update-ghcr-downloads-badge.mjs new file mode 100644 index 00000000..edab7f2a --- /dev/null +++ b/scripts/update-ghcr-downloads-badge.mjs @@ -0,0 +1,107 @@ +const DEFAULT_OUTPUT = ".github/badges/ghcr-downloads.json"; +const GH_API_BASE = "https://api.github.com"; + +const owner = process.env.GHCR_OWNER || process.env.GITHUB_REPOSITORY_OWNER; +const packageName = process.env.GHCR_PACKAGE || "charon"; +const outputPath = process.env.BADGE_OUTPUT || DEFAULT_OUTPUT; +const token = process.env.GITHUB_TOKEN || ""; + +if (!owner) { + throw new Error("GHCR owner is required. Set GHCR_OWNER or GITHUB_REPOSITORY_OWNER."); +} + +const headers = { + Accept: "application/vnd.github+json", +}; + +if (token) { + headers.Authorization = `Bearer ${token}`; +} + +const formatCount = (value) => { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(value); +}; + +const getNextLink = (linkHeader) => { + if (!linkHeader) { + return null; + } + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + return match ? match[1] : null; +}; + +const fetchPage = async (url) => { + const response = await fetch(url, { headers }); + if (!response.ok) { + const detail = await response.text(); + const error = new Error(`Request failed: ${response.status} ${response.statusText}`); + error.status = response.status; + error.detail = detail; + throw error; + } + const data = await response.json(); + const link = response.headers.get("link"); + return { data, next: getNextLink(link) }; +}; + +const fetchAllVersions = async (baseUrl) => { + let url = `${baseUrl}?per_page=100`; + const versions = []; + + while (url) { + const { data, next } = await fetchPage(url); + versions.push(...data); + url = next; + } + + return versions; +}; + +const fetchVersionsWithFallback = async () => { + const userUrl = `${GH_API_BASE}/users/${owner}/packages/container/${packageName}/versions`; + try { + return await fetchAllVersions(userUrl); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + const orgUrl = `${GH_API_BASE}/orgs/${owner}/packages/container/${packageName}/versions`; + return fetchAllVersions(orgUrl); +}; + +const run = async () => { + const versions = await fetchVersionsWithFallback(); + const totalDownloads = versions.reduce( + (sum, version) => sum + (version.download_count || 0), + 0 + ); + + const badge = { + schemaVersion: 1, + label: "GHCR pulls", + message: formatCount(totalDownloads), + color: "blue", + cacheSeconds: 3600, + }; + + const output = `${JSON.stringify(badge, null, 2)}\n`; + await import("node:fs/promises").then((fs) => fs.writeFile(outputPath, output)); + + console.log(`GHCR downloads: ${totalDownloads} -> ${outputPath}`); +}; + +run().catch((error) => { + console.error(error); + process.exit(1); +});