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 @@
+
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);
+});