diff --git a/.github/workflows/release-downloads.yml b/.github/workflows/release-downloads.yml new file mode 100644 index 000000000..33e8a4ff9 --- /dev/null +++ b/.github/workflows/release-downloads.yml @@ -0,0 +1,73 @@ +name: Release download badge + +on: + pull_request: + paths: + - .github/workflows/release-downloads.yml + - scripts/release-downloads*.mjs + push: + branches: [master] + paths: + - .github/workflows/release-downloads.yml + - scripts/release-downloads*.mjs + release: + types: [published] + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || 'master' }} + - uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - run: node --test scripts/release-downloads.test.mjs + + publish: + needs: test + if: >- + github.repository == 'mnemon-dev/mnemon' && + github.event_name != 'pull_request' && + (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/master') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + concurrency: + group: release-downloads-badge + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + ref: master + - uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - name: Count package downloads across all release pages + env: + GH_TOKEN: ${{ github.token }} + BADGE_REPOSITORY: ${{ github.repository }} + run: node scripts/release-downloads.mjs "$BADGE_REPOSITORY" "$RUNNER_TEMP/release-downloads.json" + - name: Publish badge data + run: | + git fetch origin badges:refs/remotes/origin/badges + git worktree add --detach "$RUNNER_TEMP/mnemon-badges" origin/badges + cp "$RUNNER_TEMP/release-downloads.json" "$RUNNER_TEMP/mnemon-badges/release-downloads.json" + cd "$RUNNER_TEMP/mnemon-badges" + git add release-downloads.json + if git diff --cached --quiet -- release-downloads.json; then + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'docs: refresh release package download count' + git push origin HEAD:refs/heads/badges diff --git a/README.md b/README.md index f2838ee1c..252a001e1 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@
-
+
+
diff --git a/docs/zh/README.md b/docs/zh/README.md
index 73e1b5f41..25de630b6 100644
--- a/docs/zh/README.md
+++ b/docs/zh/README.md
@@ -8,8 +8,9 @@
-
+
+
diff --git a/scripts/release-downloads.mjs b/scripts/release-downloads.mjs
new file mode 100644
index 000000000..23c986bc1
--- /dev/null
+++ b/scripts/release-downloads.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+
+import { execFileSync } from "node:child_process";
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+function formatDownloads(downloads) {
+ // Match the SI units and precision used by Shields' npm download badges.
+ const units = ["", "k", "M", "G", "T", "P"];
+ let unit = 0;
+ while (unit < units.length - 1 && downloads >= 1000 ** (unit + 1)) unit++;
+ if (unit === 0) return String(downloads);
+ const scaled = downloads / 1000 ** unit;
+ const rounded = scaled < 10 ? Number(scaled.toFixed(1)) : Math.round(scaled);
+ return rounded === 1000 ? `1${units[unit + 1]}` : `${rounded}${units[unit]}`;
+}
+
+export function releaseDownloadBadge(releases) {
+ if (!Array.isArray(releases)) throw new Error("Expected a release array");
+ let downloads = 0;
+ for (const release of releases) {
+ if (release.draft) continue;
+ if (!Array.isArray(release.assets)) throw new Error("Missing release assets");
+ for (const asset of release.assets) {
+ if (typeof asset.name !== "string") throw new Error("Missing asset name");
+ if (!/^mnemon_.+\.(?:tar\.gz|zip)$/.test(asset.name)) continue;
+ if (!Number.isSafeInteger(asset.download_count) || asset.download_count < 0) {
+ throw new Error(`Invalid download count for ${asset.name}`);
+ }
+ downloads += asset.download_count;
+ if (!Number.isSafeInteger(downloads)) throw new Error("Download count overflow");
+ }
+ }
+ return {
+ schemaVersion: 1,
+ label: "GitHub downloads",
+ message: formatDownloads(downloads),
+ color: "brightgreen",
+ };
+}
+
+export async function writeReleaseDownloadBadge(repository, output, run = execFileSync) {
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error("Expected owner/repository");
+ const pages = JSON.parse(run("gh", [
+ "api", "--paginate", "--slurp", `repos/${repository}/releases?per_page=100`,
+ ], { encoding: "utf8", timeout: 120_000, maxBuffer: 32 * 1024 * 1024 }));
+ if (!Array.isArray(pages) || pages.length === 0 || !pages.every(Array.isArray)) {
+ throw new Error("Expected paginated release arrays");
+ }
+ const badge = releaseDownloadBadge(pages.flat());
+ await writeFile(output, JSON.stringify(badge, null, 2) + "\n");
+ return badge;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+ try {
+ const [, , repository, output] = process.argv;
+ if (!repository || !output) throw new Error("Usage: release-downloads.mjs owner/repo output.json");
+ const badge = await writeReleaseDownloadBadge(repository, output);
+ process.stdout.write(`${badge.message} release package downloads\n`);
+ } catch (error) {
+ process.stderr.write(`${error.message}\n`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/release-downloads.test.mjs b/scripts/release-downloads.test.mjs
new file mode 100644
index 000000000..3b342d379
--- /dev/null
+++ b/scripts/release-downloads.test.mjs
@@ -0,0 +1,69 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { releaseDownloadBadge, writeReleaseDownloadBadge } from "./release-downloads.mjs";
+
+const asset = (name, download_count) => ({ name, download_count });
+
+test("counts published packages across versions while excluding checksums and other files", () => {
+ const badge = releaseDownloadBadge([
+ { assets: [
+ asset("mnemon_0.1.0_linux_amd64.tar.gz", 1200),
+ asset("mnemon_0.1.0_windows_amd64.zip", 34),
+ asset("checksums.txt", 9000),
+ asset("mnemon_0.1.0_linux_amd64.tar.gz.sha256", 500),
+ asset("source.zip", 70),
+ ] },
+ { prerelease: true, assets: [asset("mnemon_0.2.0-rc1_darwin_arm64.tar.gz", 56)] },
+ { draft: true, assets: [asset("mnemon_0.3.0_linux_arm64.tar.gz", 999)] },
+ ]);
+ assert.deepEqual(badge, {
+ schemaVersion: 1, label: "GitHub downloads", message: "1.3k", color: "brightgreen",
+ });
+});
+
+test("an empty public release set produces a valid zero badge", () => {
+ assert.equal(releaseDownloadBadge([]).message, "0");
+});
+
+test("formats totals with npm badge precision, including unit rollover", () => {
+ const cases = [
+ [999, "999"], [1000, "1k"], [1290, "1.3k"], [9950, "9.9k"],
+ [9999, "10k"], [10000, "10k"], [19633, "20k"], [123456, "123k"],
+ [999499, "999k"], [999500, "1M"], [1000000, "1M"], [1250000, "1.3M"],
+ [12600000, "13M"], [999500000, "1G"], [1000000000, "1G"],
+ [1000000000000, "1T"], [Number.MAX_SAFE_INTEGER, "9P"],
+ ];
+ for (const [count, expected] of cases) {
+ const badge = releaseDownloadBadge([{ assets: [asset("mnemon_1_linux_amd64.tar.gz", count)] }]);
+ assert.equal(badge.message, expected, `downloads: ${count}`);
+ }
+});
+
+test("invalid archive counts and incomplete responses fail instead of silently undercounting", () => {
+ for (const count of [-1, 1.5, "3", undefined, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.throws(() => releaseDownloadBadge([{ assets: [asset("mnemon_1_linux_amd64.tar.gz", count)] }]), /Invalid download count/);
+ }
+ assert.throws(() => releaseDownloadBadge([{}]), /Missing release assets/);
+});
+
+test("combines every API page and preserves the existing badge when refresh fails", async (t) => {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "mnemon-download-badge-"));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const output = path.join(directory, "release-downloads.json");
+ const pages = [
+ [{ assets: [asset("mnemon_2_linux_arm64.tar.gz", 7)] }],
+ [{ assets: [asset("mnemon_1_windows_amd64.zip", 11)] }],
+ ];
+ await writeReleaseDownloadBadge("mnemon-dev/mnemon", output, () => JSON.stringify(pages));
+ const previous = await readFile(output, "utf8");
+ assert.equal(JSON.parse(previous).message, "18");
+ await assert.rejects(writeReleaseDownloadBadge("mnemon-dev/mnemon", output, () => {
+ throw new Error("GitHub unavailable");
+ }), /GitHub unavailable/);
+ await assert.rejects(writeReleaseDownloadBadge("mnemon-dev/mnemon", output, () => JSON.stringify([{ message: "rate limited" }])));
+ assert.equal(await readFile(output, "utf8"), previous);
+});