diff --git a/.github/workflows/sort-download-badges.yml b/.github/workflows/sort-download-badges.yml
index 30a8a1f..13f2cca 100644
--- a/.github/workflows/sort-download-badges.yml
+++ b/.github/workflows/sort-download-badges.yml
@@ -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:
@@ -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
diff --git a/README.md b/README.md
index 7793042..63618b3 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
+
diff --git a/scripts/microsoft-store-downloads.js b/scripts/microsoft-store-downloads.js
new file mode 100644
index 0000000..198bcc6
--- /dev/null
+++ b/scripts/microsoft-store-downloads.js
@@ -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
+
+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 ');
+ 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,
+};
diff --git a/scripts/sort-download-badges.js b/scripts/sort-download-badges.js
index d61cf4b..244c843 100644
--- a/scripts/sort-download-badges.js
+++ b/scripts/sort-download-badges.js
@@ -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 = '';
const GITHUB_REPO = 'Twest2/StepForge';
@@ -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) {
@@ -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;
}
diff --git a/tests/unit/download-badges.test.js b/tests/unit/download-badges.test.js
index 48b65bb..00bfcb0 100644
--- a/tests/unit/download-badges.test.js
+++ b/tests/unit/download-badges.test.js
@@ -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: [] }]
: [];
@@ -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/')));
@@ -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/);
+});
diff --git a/tests/unit/microsoft-store-downloads.test.js b/tests/unit/microsoft-store-downloads.test.js
new file mode 100644
index 0000000..6a587c9
--- /dev/null
+++ b/tests/unit/microsoft-store-downloads.test.js
@@ -0,0 +1,141 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { makeTmpDir, rmrf } = require('./helpers');
+const { readProductId } = require('../../scripts/publish-microsoft-store');
+const {
+ BADGE_BRANCH,
+ BADGE_FILE,
+ FIRST_DAY,
+ readCredentials,
+ getAccessToken,
+ totalAcquisitions,
+ badgeJson,
+ writeBadge,
+} = require('../../scripts/microsoft-store-downloads');
+
+/**
+ * The README's Microsoft Store downloads badge
+ * (scripts/microsoft-store-downloads.js and the sort-download-badges
+ * workflow). Partner Center is replaced by a stand-in fetch.
+ */
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
+
+const ENV = {
+ MICROSOFT_STORE_TENANT_ID: 'tenant-guid',
+ MICROSOFT_STORE_CLIENT_ID: 'client-guid',
+ MICROSOFT_STORE_CLIENT_SECRET: 'client~secret',
+};
+
+const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+
+/** Answers the token request, then the given acquisition pages in order. */
+function partnerCenter(pages) {
+ const requests = [];
+ const fetchImpl = async (url, options = {}) => {
+ requests.push({ url, options });
+ if (url.startsWith('https://login.microsoftonline.com/')) return json(200, { access_token: 'tok', token_type: 'Bearer' });
+ return json(200, pages.shift());
+ };
+ return { fetchImpl, requests };
+}
+
+test('signing in uses the client-credentials grant for the Partner Center API', async () => {
+ const { fetchImpl, requests } = partnerCenter([]);
+ assert.equal(await getAccessToken(readCredentials(ENV), fetchImpl), 'tok');
+ const [{ url, options }] = requests;
+ assert.equal(url, 'https://login.microsoftonline.com/tenant-guid/oauth2/token');
+ assert.equal(options.method, 'POST');
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(options.body)), {
+ grant_type: 'client_credentials',
+ client_id: 'client-guid',
+ client_secret: 'client~secret',
+ resource: 'https://manage.devcenter.microsoft.com',
+ });
+});
+
+test('a refused sign-in reports the status and error code, never the secret', async () => {
+ const fetchImpl = async () => json(401, { error: 'invalid_client', error_description: 'AADSTS7000215: Invalid client secret.' });
+ await assert.rejects(getAccessToken(readCredentials(ENV), fetchImpl), (err) => {
+ assert.equal(err.message, 'Partner Center sign-in failed: HTTP 401 invalid_client');
+ return true;
+ });
+ assert.throws(() => readCredentials({ ...ENV, MICROSOFT_STORE_CLIENT_SECRET: ' ' }), /^Error: Set MICROSOFT_STORE_CLIENT_SECRET to read/);
+});
+
+test('every acquisition since before launch is added up, across pages', async () => {
+ const { fetchImpl, requests } = partnerCenter([
+ { Value: [{ date: '2026-10-01', acquisitionQuantity: 12 }, { date: '2026-11-01', acquisitionQuantity: 3 }], '@nextLink': 'appacquisitions?applicationId=9NDH81CF1HGV&skip=2', TotalCount: 3 },
+ { value: [{ date: '2026-12-01', acquisitionQuantity: 4 }], TotalCount: 3 },
+ ]);
+ const total = await totalAcquisitions({ token: 'tok', productId: '9NDH81CF1HGV', today: new Date('2026-12-15T10:00:00Z'), fetchImpl });
+ assert.equal(total, 19);
+
+ const first = new URL(requests[0].url);
+ assert.equal(first.origin + first.pathname, 'https://manage.devcenter.microsoft.com/v1.0/my/analytics/appacquisitions');
+ assert.deepEqual(Object.fromEntries(first.searchParams), {
+ applicationId: '9NDH81CF1HGV', startDate: FIRST_DAY, endDate: '2026-12-15', aggregationLevel: 'month', top: '10000',
+ });
+ assert.equal(requests[0].options.headers.Authorization, 'Bearer tok');
+ // A relative next link continues from the same endpoint.
+ assert.equal(requests[1].url, 'https://manage.devcenter.microsoft.com/v1.0/my/analytics/appacquisitions?applicationId=9NDH81CF1HGV&skip=2');
+ assert.ok(FIRST_DAY < '2026-10-01', 'the range starts before StepForge was in the Store');
+});
+
+test('an app without acquisitions counts zero; a bad row or failed page aborts', async () => {
+ assert.equal(await totalAcquisitions({ token: 't', productId: 'X', fetchImpl: partnerCenter([{ Value: [] }]).fetchImpl }), 0);
+ await assert.rejects(
+ totalAcquisitions({ token: 't', productId: 'X', fetchImpl: partnerCenter([{ Value: [{ acquisitionQuantity: 'many' }] }]).fetchImpl }),
+ /Unexpected acquisition row/,
+ );
+ await assert.rejects(totalAcquisitions({ token: 't', productId: 'X', fetchImpl: async () => json(403, {}) }), /HTTP 403/);
+});
+
+test('the badge file is a shields.io endpoint badge for the Store ID in identity.json', async () => {
+ assert.deepEqual(badgeJson(42), { schemaVersion: 1, label: 'Microsoft Store downloads', message: '42', color: 'blue' });
+ const dir = makeTmpDir('store-downloads');
+ try {
+ const { fetchImpl, requests } = partnerCenter([{ Value: [{ acquisitionQuantity: 8 }] }]);
+ const output = path.join(dir, 'badges', BADGE_FILE);
+ assert.equal(await writeBadge(output, { env: ENV, fetchImpl }), 8);
+ assert.deepEqual(JSON.parse(fs.readFileSync(output, 'utf8')), badgeJson(8));
+ assert.equal(new URL(requests[1].url).searchParams.get('applicationId'), readProductId());
+ } finally {
+ rmrf(dir);
+ }
+});
+
+test('the README badge reads the file the workflow keeps on the badges branch', () => {
+ const readme = read('README.md');
+ const img = /
/.exec(readme);
+ assert.ok(img, 'the README has the badge');
+ const src = new URL(img[1].replace(/&/g, '&'));
+ assert.equal(src.origin + src.pathname, 'https://img.shields.io/endpoint');
+ assert.equal(src.searchParams.get('url'), `https://raw.githubusercontent.com/Twest2/StepForge/${BADGE_BRANCH}/${BADGE_FILE}`);
+});
+
+test('the daily workflow refreshes the count, rebuilds the branch, then sorts', () => {
+ const workflow = read('.github/workflows/sort-download-badges.yml');
+ const store = workflow.split('\n microsoft-store:\n')[1].split('\n sort:\n')[0];
+ const sort = workflow.split('\n sort:\n')[1];
+ for (const name of ['TENANT_ID', 'CLIENT_ID']) {
+ assert.match(store, new RegExp(`MICROSOFT_STORE_${name}: \\$\\{\\{ vars\\.MICROSOFT_STORE_${name} \\}\\}\\n`));
+ }
+ assert.match(store, /MICROSOFT_STORE_CLIENT_SECRET: \$\{\{ secrets\.MICROSOFT_STORE_CLIENT_SECRET \}\}\n/);
+ assert.match(store, new RegExp(`node scripts/microsoft-store-downloads\\.js "\\$RUNNER_TEMP/badges/${BADGE_FILE.replace('.', '\\.')}"`));
+ // One parentless commit, force-pushed: the branch never grows and comes back if deleted.
+ assert.match(store, /git commit-tree "\$tree" -m /);
+ assert.doesNotMatch(store, /commit-tree [^\n]*-p /);
+ assert.match(store, new RegExp(`git push --force origin "\\$commit:refs/heads/${BADGE_BRANCH}"`));
+ assert.match(store, new RegExp(`git fetch --quiet --depth=1 origin refs/heads/${BADGE_BRANCH}`));
+ assert.match(sort, /needs: microsoft-store\n/);
+ assert.match(sort, /if: \$\{\{ !cancelled\(\) \}\}\n/);
+ assert.match(workflow, /schedule:\n\s+- cron: /);
+ assert.match(workflow, /workflow_dispatch:/);
+});