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 @@ GitHub downloads Chocolatey downloads APT downloads + Microsoft Store downloads DNF downloads

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 = /Microsoft Store downloads/.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:/); +});