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 @@

npm version - npm downloads + npm downloads GitHub release + GitHub release package downloads GitHub stars Go 1.24+ CI 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 @@

npm 版本 - npm 累计下载量 + npm 累计下载量 GitHub 发布版本 + 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..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); +});