From 9daaff24bf9eb6e17d8f51dc8dd6fabe1080959d Mon Sep 17 00:00:00 2001
From: Twest2
Date: Sat, 31 Oct 2026 01:06:14 -0500
Subject: [PATCH] Add a Microsoft Store downloads badge to the README
Microsoft publishes no public download count, so the daily Sort
download badges workflow gains a first job that reads StepForge's total
Store acquisitions from Partner Center's analytics API, with the Entra
app the release already uses (scripts/microsoft-store-downloads.js),
and writes a shields.io endpoint file to a badges branch.
The branch is always one parentless commit, rebuilt and force-pushed
only when the count changes, so it never grows and comes back on the
next run if it is deleted. The sort job reads the count from that
branch through the contents API and still runs, with the last published
count, when Partner Center can't be read.
---
.github/workflows/sort-download-badges.yml | 58 +++++++-
README.md | 1 +
scripts/microsoft-store-downloads.js | 122 ++++++++++++++++
scripts/sort-download-badges.js | 20 ++-
tests/unit/download-badges.test.js | 24 +++-
tests/unit/microsoft-store-downloads.test.js | 141 +++++++++++++++++++
6 files changed, 359 insertions(+), 7 deletions(-)
create mode 100644 scripts/microsoft-store-downloads.js
create mode 100644 tests/unit/microsoft-store-downloads.test.js
diff --git a/.github/workflows/sort-download-badges.yml b/.github/workflows/sort-download-badges.yml
index 30a8a1ff..13f2cca7 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 2bb9b0dc..c2c5b916 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 00000000..198bcc68
--- /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 d61cf4b6..244c8430 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 48b65bb1..00bfcb04 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 00000000..6a587c94
--- /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:/);
+});