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
58 changes: 56 additions & 2 deletions .github/workflows/sort-download-badges.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Sort download badges

# The README's download badges show live counts, but their order is static.
# Once a day, reorder them from most to fewest downloads and commit the README
# only when the order actually changed.
# Once a day, refresh the Microsoft Store count, then reorder the badges from
# most to fewest downloads and commit the README only when the order actually
# changed.

on:
schedule:
Expand All @@ -17,8 +18,61 @@ concurrency:
cancel-in-progress: false

jobs:
microsoft-store:
name: Update the Microsoft Store download count
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc

# Partner Center has no public count, so it's read with the release's
# Entra app and served to shields.io from the badges branch.
- name: Read the count from Partner Center
env:
MICROSOFT_STORE_TENANT_ID: ${{ vars.MICROSOFT_STORE_TENANT_ID }}
MICROSOFT_STORE_CLIENT_ID: ${{ vars.MICROSOFT_STORE_CLIENT_ID }}
MICROSOFT_STORE_CLIENT_SECRET: ${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}
run: node scripts/microsoft-store-downloads.js "$RUNNER_TEMP/badges/microsoft-store-downloads.json"

# The branch is always one commit holding only the badge files, rebuilt
# and force-pushed, so it never grows and a deleted branch comes back on
# the next run.
- name: Publish it on the badges branch
env:
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898+github-actions[bot]@users.noreply.github.com
run: |
set -euo pipefail
file=microsoft-store-downloads.json
if git fetch --quiet --depth=1 origin refs/heads/badges 2>/dev/null &&
git show "FETCH_HEAD:$file" 2>/dev/null | cmp -s - "$RUNNER_TEMP/badges/$file"; then
echo 'The badges branch already has this count.'
exit 0
fi
printf '%s\n' \
'# Badges' \
'' \
'Generated by .github/workflows/sort-download-badges.yml for the' \
'Microsoft Store downloads badge in the README. Every run replaces this branch' \
'with a single commit, so it is safe to delete: the next run creates it again.' \
> "$RUNNER_TEMP/badges/README.md"
tree=$(printf '100644 blob %s\t%s\n' \
"$(git hash-object -w "$RUNNER_TEMP/badges/README.md")" README.md \
"$(git hash-object -w "$RUNNER_TEMP/badges/$file")" "$file" | git mktree)
commit=$(git commit-tree "$tree" -m 'Update the Microsoft Store download count')
git push --force origin "$commit:refs/heads/badges"

sort:
name: Sort README download badges
needs: microsoft-store
# When Partner Center can't be read, sort with the count already on the
# badges branch; the failed job above still marks the run as failed.
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<a href="https://github.com/Twest2/StepForge/releases"><img alt="GitHub downloads" src="https://img.shields.io/github/downloads/Twest2/StepForge/total?label=GitHub%20downloads&color=blue"></a>
<a href="docs/windows/chocolatey.md"><img alt="Chocolatey downloads" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpackages.twestbrook.com%2Fapi%2Fpackages%2Fstepforge-choco%2Fversions%3Fname%3Dstepforge&query=%24%5B0%5D.totalDownloads&label=Chocolatey%20downloads&color=blue&cacheSeconds=3600"></a>
<a href="docs/linux/apt.md"><img alt="APT downloads" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpackages.twestbrook.com%2Fapi%2Fpackages%2Fstepforge%2Fversions%3Fname%3Dstepforge&query=%24%5B0%5D.totalDownloads&label=APT%20downloads&color=blue&cacheSeconds=3600"></a>
<a href="docs/windows/microsoft-store.md"><img alt="Microsoft Store downloads" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FTwest2%2FStepForge%2Fbadges%2Fmicrosoft-store-downloads.json&cacheSeconds=3600"></a>
<a href="docs/linux/dnf.md"><img alt="DNF downloads" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpackages.twestbrook.com%2Fapi%2Fpackages%2Fstepforge-rpm%2Fversions%3Fname%3Dstepforge&query=%24%5B0%5D.totalDownloads&label=DNF%20downloads&color=blue&cacheSeconds=3600"></a>
</p>
<!-- download-badges:end -->
Expand Down
122 changes: 122 additions & 0 deletions scripts/microsoft-store-downloads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
'use strict';

// Writes the README's Microsoft Store downloads badge: a shields.io endpoint
// file with StepForge's total Store acquisitions. Microsoft publishes no
// public count, so this reads Partner Center's analytics API with the Entra
// app the release uses (MICROSOFT_STORE_TENANT_ID, MICROSOFT_STORE_CLIENT_ID,
// MICROSOFT_STORE_CLIENT_SECRET). The sort-download-badges workflow runs it
// daily and keeps the file on the badges branch.
//
// node scripts/microsoft-store-downloads.js <output.json>

const fs = require('node:fs');
const path = require('node:path');

const { readProductId } = require('./publish-microsoft-store');

const BADGE_BRANCH = 'badges';
const BADGE_FILE = 'microsoft-store-downloads.json';
const LABEL = 'Microsoft Store downloads';
const RESOURCE = 'https://manage.devcenter.microsoft.com';
const ACQUISITIONS_URL = `${RESOURCE}/v1.0/my/analytics/appacquisitions`;
// Before StepForge was in the Store, so the range covers every acquisition.
const FIRST_DAY = '2026-01-01';

const CREDENTIALS = ['MICROSOFT_STORE_TENANT_ID', 'MICROSOFT_STORE_CLIENT_ID', 'MICROSOFT_STORE_CLIENT_SECRET'];

function readCredentials(env = process.env) {
const missing = CREDENTIALS.filter((name) => !String(env[name] || '').trim());
if (missing.length) throw new Error(`Set ${missing.join(', ')} to read Microsoft Store downloads.`);
const [tenantId, clientId, clientSecret] = CREDENTIALS.map((name) => env[name].trim());
return { tenantId, clientId, clientSecret };
}

async function getAccessToken({ tenantId, clientId, clientSecret }, fetchImpl = fetch) {
const response = await fetchImpl(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, resource: RESOURCE }).toString(),
});
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.access_token) {
throw new Error(`Partner Center sign-in failed: HTTP ${response.status}${body.error ? ` ${body.error}` : ''}`);
}
return body.access_token;
}

// The analytics API answers with "Value" and "@nextLink"; read them without case.
function prop(object, name) {
const key = object && Object.keys(object).find((k) => k.toLowerCase() === name.toLowerCase());
return key === undefined ? undefined : object[key];
}

/** Every Store acquisition of the app, summed over monthly rows. */
async function totalAcquisitions({ token, productId, today = new Date(), fetchImpl = fetch }) {
const params = new URLSearchParams({
applicationId: productId,
startDate: FIRST_DAY,
endDate: today.toISOString().slice(0, 10),
aggregationLevel: 'month',
top: '10000',
});
let url = `${ACQUISITIONS_URL}?${params}`;
let total = 0;
for (let page = 0; url; page += 1) {
if (page >= 100) throw new Error('Partner Center kept returning more acquisition pages.');
const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
if (!response.ok) throw new Error(`Reading Microsoft Store acquisitions failed: HTTP ${response.status}`);
const body = await response.json();
for (const row of prop(body, 'Value') || []) {
const quantity = prop(row, 'acquisitionQuantity');
if (!Number.isFinite(quantity) || quantity < 0) throw new Error(`Unexpected acquisition row: ${JSON.stringify(row)}`);
total += quantity;
}
const next = prop(body, '@nextLink');
url = next ? new URL(next, url).toString() : null;
}
return total;
}

/** The shields.io endpoint badge (https://shields.io/badges/endpoint-badge). */
function badgeJson(count) {
return { schemaVersion: 1, label: LABEL, message: String(count), color: 'blue' };
}

async function writeBadge(output, { env = process.env, fetchImpl = fetch, today, identityFile } = {}) {
const credentials = readCredentials(env);
const productId = readProductId(identityFile);
const token = await getAccessToken(credentials, fetchImpl);
const count = await totalAcquisitions({ token, productId, today, fetchImpl });
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(badgeJson(count))}\n`);
return count;
}

if (require.main === module) {
const output = process.argv[2];
if (!output) {
console.error('Usage: node scripts/microsoft-store-downloads.js <output.json>');
process.exitCode = 2;
} else {
writeBadge(path.resolve(output)).then(
(count) => console.log(`${LABEL}: ${count}`),
(err) => {
console.error(err.message || err);
process.exitCode = 1;
},
);
}
}

module.exports = {
BADGE_BRANCH,
BADGE_FILE,
LABEL,
FIRST_DAY,
readCredentials,
getAccessToken,
totalAcquisitions,
badgeJson,
writeBadge,
};
20 changes: 17 additions & 3 deletions scripts/sort-download-badges.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
const fs = require('node:fs');
const path = require('node:path');

const { BADGE_BRANCH, BADGE_FILE } = require('./microsoft-store-downloads');

const START_MARKER = '<!-- download-badges:start';
const END_MARKER = '<!-- download-badges:end -->';
const GITHUB_REPO = 'Twest2/StepForge';
Expand All @@ -20,6 +22,7 @@ const SOURCES = {
'APT downloads': { kind: 'proget', feed: 'stepforge' },
'DNF downloads': { kind: 'proget', feed: 'stepforge-rpm' },
'Chocolatey downloads': { kind: 'proget', feed: 'stepforge-choco' },
'Microsoft Store downloads': { kind: 'badge-branch', repo: GITHUB_REPO, branch: BADGE_BRANCH, file: BADGE_FILE },
};

function badgeLabel(line) {
Expand Down Expand Up @@ -107,14 +110,25 @@ async function progetDownloads(fetchImpl, feed) {
throw new Error(`shields.io could not read ProGet feed ${feed}: ${value || 'empty response'}`);
}

// Partner Center has no public count, so the workflow's first job writes it
// to the badges branch. Read it through the contents API, which, unlike
// raw.githubusercontent.com, isn't cached for minutes after that push.
async function badgeBranchDownloads(fetchImpl, { repo, branch, file }, token) {
const headers = { Accept: 'application/vnd.github.raw+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
const badge = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/contents/${file}?ref=${branch}`, headers);
const value = String(badge.message ?? '');
if (/^\d+$/.test(value)) return Number(value);
throw new Error(`${file} on the ${branch} branch has no count: ${value || 'empty message'}`);
}

async function fetchCounts(labels, { fetchImpl = fetch, token } = {}) {
const counts = {};
for (const label of labels) {
const source = SOURCES[label];
if (!source) throw new Error(`No download source configured for badge "${label}"`);
counts[label] = source.kind === 'github'
? await githubDownloads(fetchImpl, source.repo, token)
: await progetDownloads(fetchImpl, source.feed);
if (source.kind === 'github') counts[label] = await githubDownloads(fetchImpl, source.repo, token);
else if (source.kind === 'badge-branch') counts[label] = await badgeBranchDownloads(fetchImpl, source, token);
else counts[label] = await progetDownloads(fetchImpl, source.feed);
}
return counts;
}
Expand Down
24 changes: 22 additions & 2 deletions tests/unit/download-badges.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,14 @@ test('every badge in the real README has a configured download source', () => {
assert.deepEqual([...labels].sort(), Object.keys(SOURCES).sort());
});

test('counts come from GitHub release assets and ProGet totalDownloads via shields.io', async () => {
test('counts come from GitHub release assets, ProGet totalDownloads via shields.io, and the badges branch', async () => {
const requested = [];
const fetchImpl = async (url, options) => {
requested.push({ url, auth: options.headers.Authorization });
let body;
if (url.startsWith('https://api.github.com/')) {
if (url.includes('/contents/')) {
body = { schemaVersion: 1, label: 'Microsoft Store downloads', message: '31', color: 'blue' };
} else if (url.startsWith('https://api.github.com/')) {
body = url.endsWith('page=1')
? [{ assets: [{ download_count: 10 }, { download_count: 2 }] }, { assets: [] }]
: [];
Expand All @@ -96,6 +98,7 @@ test('counts come from GitHub release assets and ProGet totalDownloads via shiel
'APT downloads': 18,
'DNF downloads': 0,
'Chocolatey downloads': 18,
'Microsoft Store downloads': 31,
});
assert.ok(requested.some((r) => r.url.includes('/repos/Twest2/StepForge/releases') && r.auth === 'Bearer t0k'));
const choco = requested.find((r) => r.url.includes(encodeURIComponent('/stepforge-choco/')));
Expand All @@ -120,3 +123,20 @@ test('a failed request aborts the sort', async () => {
const fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
await assert.rejects(fetchCounts(['APT downloads'], { fetchImpl }), /HTTP 503/);
});

test('the Microsoft Store count is read from the badges branch through the contents API', async () => {
const requested = [];
const fetchImpl = async (url, options) => {
requested.push({ url, headers: options.headers });
return { ok: true, status: 200, json: async () => ({ schemaVersion: 1, label: 'Microsoft Store downloads', message: '31', color: 'blue' }) };
};
assert.deepEqual(await fetchCounts(['Microsoft Store downloads'], { fetchImpl, token: 't0k' }), { 'Microsoft Store downloads': 31 });
assert.equal(requested[0].url, 'https://api.github.com/repos/Twest2/StepForge/contents/microsoft-store-downloads.json?ref=badges');
assert.equal(requested[0].headers.Accept, 'application/vnd.github.raw+json');
assert.equal(requested[0].headers.Authorization, 'Bearer t0k');

// A missing branch or an unreadable file aborts instead of counting as zero.
await assert.rejects(fetchCounts(['Microsoft Store downloads'], { fetchImpl: async () => ({ ok: false, status: 404, json: async () => ({}) }) }), /HTTP 404/);
const empty = async () => ({ ok: true, status: 200, json: async () => ({ message: '' }) });
await assert.rejects(fetchCounts(['Microsoft Store downloads'], { fetchImpl: empty }), /has no count/);
});
Loading
Loading