From 63ac1b0f7d80b5747646e932d90cb5398b8754fb Mon Sep 17 00:00:00 2001 From: GRIVN Date: Tue, 20 Oct 2026 03:29:15 +0800 Subject: [PATCH 1/3] docs: add GitHub release download badges Show cumulative GitHub release asset downloads in the English and Chinese README headers, with links to the releases page. Validated both live Shields SVG responses, checked the total against the GitHub releases API, and passed git diff --check. --- README.md | 1 + docs/zh/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index f2838ee1c..e85fca883 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ npm version npm downloads GitHub release + GitHub release downloads GitHub stars Go 1.24+ CI diff --git a/docs/zh/README.md b/docs/zh/README.md index 73e1b5f41..05c69c81e 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -10,6 +10,7 @@ npm 版本 npm 累计下载量 GitHub 发布版本 + GitHub Release 累计下载量 GitHub 收藏数 Go 1.24+ CI From 326186a4abb3d62dd01d12ce33d3bed82c2835f3 Mon Sep 17 00:00:00 2001 From: GRIVN Date: Tue, 20 Oct 2026 03:40:17 +0800 Subject: [PATCH 2/3] docs: distinguish npm and package-only release downloads Label npm totals explicitly in both READMEs and count only published Mnemon archives for the GitHub badge. Refresh the separate badge data branch every six hours and on releases, with read-only tests for pull requests. Validated four counter tests, actionlint, and live npm and GitHub badge SVGs. The live release count excludes 4,334 checksum downloads from the all-asset total. --- .github/workflows/release-downloads.yml | 73 +++++++++++++++++++++++++ README.md | 4 +- docs/zh/README.md | 4 +- scripts/release-downloads.mjs | 55 +++++++++++++++++++ scripts/release-downloads.test.mjs | 55 +++++++++++++++++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-downloads.yml create mode 100644 scripts/release-downloads.mjs create mode 100644 scripts/release-downloads.test.mjs 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 e85fca883..252a001e1 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@

npm version - npm downloads + npm downloads GitHub release - GitHub release downloads + GitHub release package downloads GitHub stars Go 1.24+ CI diff --git a/docs/zh/README.md b/docs/zh/README.md index 05c69c81e..25de630b6 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -8,9 +8,9 @@

npm 版本 - npm 累计下载量 + npm 累计下载量 GitHub 发布版本 - GitHub Release 累计下载量 + GitHub Release 安装包累计下载量 GitHub 收藏数 Go 1.24+ CI diff --git a/scripts/release-downloads.mjs b/scripts/release-downloads.mjs new file mode 100644 index 000000000..11832e2a2 --- /dev/null +++ b/scripts/release-downloads.mjs @@ -0,0 +1,55 @@ +#!/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"; + +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: downloads.toLocaleString("en-US"), + 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..ec7a87951 --- /dev/null +++ b/scripts/release-downloads.test.mjs @@ -0,0 +1,55 @@ +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,290", color: "brightgreen", + }); +}); + +test("an empty public release set produces a valid zero badge", () => { + assert.equal(releaseDownloadBadge([]).message, "0"); +}); + +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); +}); From 4af58dd427430952b2247ee9b60c51f877c95a67 Mon Sep 17 00:00:00 2001 From: GRIVN Date: Tue, 20 Oct 2026 03:42:55 +0800 Subject: [PATCH 3/3] docs: match release download precision to npm badges Format release download totals with the same compact SI units and rounding as Shields npm badges, so counts such as 19,633 display as 20k. Package filtering and cumulative counting remain unchanged. Validated five counter tests, including precision and unit rollover, and generated the updated badge from the live GitHub release API. --- scripts/release-downloads.mjs | 13 ++++++++++++- scripts/release-downloads.test.mjs | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/release-downloads.mjs b/scripts/release-downloads.mjs index 11832e2a2..23c986bc1 100644 --- a/scripts/release-downloads.mjs +++ b/scripts/release-downloads.mjs @@ -5,6 +5,17 @@ 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; @@ -24,7 +35,7 @@ export function releaseDownloadBadge(releases) { return { schemaVersion: 1, label: "GitHub downloads", - message: downloads.toLocaleString("en-US"), + message: formatDownloads(downloads), color: "brightgreen", }; } diff --git a/scripts/release-downloads.test.mjs b/scripts/release-downloads.test.mjs index ec7a87951..3b342d379 100644 --- a/scripts/release-downloads.test.mjs +++ b/scripts/release-downloads.test.mjs @@ -21,7 +21,7 @@ test("counts published packages across versions while excluding checksums and ot { draft: true, assets: [asset("mnemon_0.3.0_linux_arm64.tar.gz", 999)] }, ]); assert.deepEqual(badge, { - schemaVersion: 1, label: "GitHub downloads", message: "1,290", color: "brightgreen", + schemaVersion: 1, label: "GitHub downloads", message: "1.3k", color: "brightgreen", }); }); @@ -29,6 +29,20 @@ 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/);