Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/release-downloads.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

<p align="center">
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm version" src="https://img.shields.io/npm/v/@mnemon-dev/mnemon?label=npm" /></a>
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm downloads" src="https://img.shields.io/npm/dt/@mnemon-dev/mnemon?label=downloads%20total" /></a>
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm downloads" src="https://img.shields.io/npm/dt/@mnemon-dev/mnemon?label=npm%20downloads" /></a>
<a href="https://github.com/mnemon-dev/mnemon/releases/latest"><img alt="GitHub release" src="https://img.shields.io/github/v/release/mnemon-dev/mnemon" /></a>
<a href="https://github.com/mnemon-dev/mnemon/releases"><img alt="GitHub release package downloads" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmnemon-dev%2Fmnemon%2Fbadges%2Frelease-downloads.json&amp;label=GitHub%20downloads" /></a>
<a href="https://github.com/mnemon-dev/mnemon/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/mnemon-dev/mnemon?label=stars" /></a>
<a href="https://go.dev/"><img alt="Go 1.24+" src="https://img.shields.io/badge/Go-1.24%2B-00ADD8?logo=go&amp;logoColor=white" /></a>
<a href="https://github.com/mnemon-dev/mnemon/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mnemon-dev/mnemon/actions/workflows/ci.yml/badge.svg" /></a>
Expand Down
3 changes: 2 additions & 1 deletion docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

<p align="center">
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm 版本" src="https://img.shields.io/npm/v/@mnemon-dev/mnemon?label=npm" /></a>
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm 累计下载量" src="https://img.shields.io/npm/dt/@mnemon-dev/mnemon?label=%E7%B4%AF%E8%AE%A1%E4%B8%8B%E8%BD%BD" /></a>
<a href="https://www.npmjs.com/package/@mnemon-dev/mnemon"><img alt="npm 累计下载量" src="https://img.shields.io/npm/dt/@mnemon-dev/mnemon?label=npm%20%E4%B8%8B%E8%BD%BD" /></a>
<a href="https://github.com/mnemon-dev/mnemon/releases/latest"><img alt="GitHub 发布版本" src="https://img.shields.io/github/v/release/mnemon-dev/mnemon" /></a>
<a href="https://github.com/mnemon-dev/mnemon/releases"><img alt="GitHub Release 安装包累计下载量" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmnemon-dev%2Fmnemon%2Fbadges%2Frelease-downloads.json&amp;label=GitHub%20%E7%B4%AF%E8%AE%A1%E4%B8%8B%E8%BD%BD" /></a>
<a href="https://github.com/mnemon-dev/mnemon/stargazers"><img alt="GitHub 收藏数" src="https://img.shields.io/github/stars/mnemon-dev/mnemon?label=stars" /></a>
<a href="https://go.dev/"><img alt="Go 1.24+" src="https://img.shields.io/badge/Go-1.24%2B-00ADD8?logo=go&amp;logoColor=white" /></a>
<a href="https://github.com/mnemon-dev/mnemon/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mnemon-dev/mnemon/actions/workflows/ci.yml/badge.svg" /></a>
Expand Down
66 changes: 66 additions & 0 deletions scripts/release-downloads.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
69 changes: 69 additions & 0 deletions scripts/release-downloads.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading