diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f64ccf2e..2cfc4289 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,8 +1,9 @@
name: Release
-# Manual release: all three platform packages build concurrently, followed by
-# one GitHub Release and concurrent ProGet publication for the Linux packages
-# and the Windows Chocolatey package.
+# Manual release: the Windows installer, the Microsoft Store package and the
+# two Linux packages build concurrently, followed by one GitHub Release, then
+# concurrent ProGet publication for the Linux packages and the Windows
+# Chocolatey package, and submission of the Store package to Partner Center.
on:
workflow_dispatch:
inputs:
@@ -97,18 +98,60 @@ jobs:
path: releases/*.exe
if-no-files-found: error
- # Built from the same stamped checkout, unsigned: Partner Center signs it.
- # It is uploaded to Partner Center by hand, not attached to the release
- # (docs/windows/MICROSOFT_STORE_RELEASE.md). Skipped until
- # packaging/windows/store/identity.json is filled in.
+
+ build-windows-store:
+ needs: authorize-release
+ name: Build Microsoft Store package
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ # Recheck on every job: rerunning failed jobs can reuse an earlier authorization.
+ - name: Verify release owner
+ run: node scripts/check-release-access.js
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Configure StepForge Google sign-in
+ env:
+ STEPFORGE_GOOGLE_CLIENT_ID: ${{ vars.STEPFORGE_GOOGLE_CLIENT_ID }}
+ STEPFORGE_GOOGLE_CLIENT_SECRET: ${{ secrets.STEPFORGE_GOOGLE_CLIENT_SECRET }}
+ run: node scripts/configure-google-oauth.js
+
+ - name: Configure StepForge GitHub sign-in
+ env:
+ STEPFORGE_GITHUB_CLIENT_ID: ${{ vars.STEPFORGE_GITHUB_CLIENT_ID }}
+ STEPFORGE_GITHUB_APP_SLUG: ${{ vars.STEPFORGE_GITHUB_APP_SLUG }}
+ run: node scripts/configure-github-app.js
+
+ - name: Configure StepForge OneDrive and Dropbox sign-in
+ env:
+ STEPFORGE_ONEDRIVE_CLIENT_ID: ${{ vars.STEPFORGE_ONEDRIVE_CLIENT_ID }}
+ STEPFORGE_DROPBOX_APP_KEY: ${{ vars.STEPFORGE_DROPBOX_APP_KEY }}
+ run: node scripts/configure-cloud-apps.js
+
+ - name: Set version from input
+ shell: bash
+ env:
+ VERSION: ${{ inputs.version }}
+ run: node scripts/stamp-version.js "${VERSION#v}"
+
+ # Unsigned: Partner Center signs it. It isn't attached to the GitHub
+ # Release; publish-microsoft-store submits it to Partner Center.
- name: Package Microsoft Store .appx
- run: node scripts/package-windows-store.js --if-configured
+ run: npm run package:windows:store
- uses: actions/upload-artifact@v4
with:
name: microsoft-store-package
path: releases/*.appx
- if-no-files-found: ignore
+ if-no-files-found: error
build-linux:
@@ -239,6 +282,7 @@ jobs:
name: Publish GitHub Release
needs:
- build-windows
+ - build-windows-store
- build-linux
- build-fedora
@@ -723,3 +767,40 @@ jobs:
choco push $package.FullName --source "$base/nuget/$env:PROGET_CHOCO_FEED/" --api-key $env:PROGET_API_KEY --execution-timeout 1800
if ($LASTEXITCODE -ne 0) { throw "choco push failed with exit code $LASTEXITCODE" }
+
+
+ publish-microsoft-store:
+ name: Submit to the Microsoft Store
+ needs: release
+ if: inputs.prerelease == false
+ runs-on: windows-latest
+ # Uploading and Partner Center's checks take minutes; certification
+ # itself happens after this job, within about three days.
+ timeout-minutes: 60
+
+ steps:
+ - uses: actions/checkout@v4
+ # Recheck on every job: rerunning failed jobs can reuse an earlier authorization.
+ - name: Verify release owner
+ run: node scripts/check-release-access.js
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: microsoft-store-package
+ path: dist/microsoft-store
+
+ - name: Set up the Microsoft Store Developer CLI
+ uses: microsoft/microsoft-store-apppublisher@v1.4
+ with:
+ version: v0.4.3
+
+ # Signs in as an Entra ID app with the Manager role in Partner Center,
+ # replaces the live package with this one and commits the submission.
+ # A rerun skips a version that is already in certification or live.
+ - name: Submit the package for certification
+ env:
+ MICROSOFT_STORE_TENANT_ID: ${{ vars.MICROSOFT_STORE_TENANT_ID }}
+ MICROSOFT_STORE_SELLER_ID: ${{ vars.MICROSOFT_STORE_SELLER_ID }}
+ MICROSOFT_STORE_CLIENT_ID: ${{ vars.MICROSOFT_STORE_CLIENT_ID }}
+ MICROSOFT_STORE_CLIENT_SECRET: ${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}
+ run: node scripts/publish-microsoft-store.js dist/microsoft-store
diff --git a/README.md b/README.md
index fdbd526f..2bb9b0dc 100644
--- a/README.md
+++ b/README.md
@@ -125,9 +125,11 @@ Chocolatey, APT or DNF keeps StepForge up to date with the rest of your system,
and with Chocolatey, **Settings → About → Update now** installs a new version
in one click.
-**Windows:** open [StepForge in the Microsoft Store](https://apps.microsoft.com/search?query=StepForge)
+**Windows:** open [StepForge in the Microsoft Store](https://apps.microsoft.com/detail/9NDH81CF1HGV)
and click **Get**.
+
+
Windows (Chocolatey) commands
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 47711c66..a162e7ff 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -181,14 +181,14 @@ Chinese and Japanese. `core/i18n.js` explains how it works. In short:
To add a language, add it to `LANGUAGES` in `core/i18n.js`, add its catalog
to `core/locales/` and `core/locales/index.js`, and load it with a script tag
in `app/renderer/index.html`. The Microsoft Store package lists every language
-in `LANGUAGES` (see `docs/windows/MICROSOFT_STORE_RELEASE.md`).
+in `LANGUAGES`, so the Store shows StepForge as available in it.
## Build installable packages
| Target | Command | Output |
| --- | --- | --- |
| Windows installer | `npm run package:windows` (or `pwsh scripts/package-windows.ps1`) | `releases/` |
-| Microsoft Store `.appx` (Windows only) | `npm run package:windows:store` (or `npm run package:windows:store:test` before the app is reserved); see [the release guide](windows/MICROSOFT_STORE_RELEASE.md) | `releases/` |
+| Microsoft Store `.appx` (Windows only) | `npm run package:windows:store` (or `npm run package:windows:store:test` for a placeholder identity). The Release workflow builds it and submits it to the Store | `releases/` |
| Ubuntu `.deb` + portable `.tar.gz` | `npm run package:linux:deb` | `build/artifacts/` |
| Fedora `.rpm` | `npm run package:linux:rpm` | `build/artifacts/x86_64/` |
| Unpacked app folder | `bash scripts/build-release.sh` | `build/` |
diff --git a/docs/windows/microsoft-store.md b/docs/windows/microsoft-store.md
index 8ad65c43..75aebf38 100644
--- a/docs/windows/microsoft-store.md
+++ b/docs/windows/microsoft-store.md
@@ -1,9 +1,5 @@
# Install StepForge from the Microsoft Store
-
-Note that support for this installation method is coming in the next couple of days. Microsoft is currently verifying the app package.
-
-
The Microsoft Store is the recommended way to install StepForge on
**Windows 10 (version 1809 or later) and Windows 11, 64-bit**. Microsoft
checks and signs the package, so Windows shows no **Windows protected your
@@ -15,7 +11,7 @@ Prefer a package manager or a regular installer? See
## Install
-1. Open [StepForge in the Microsoft Store](https://apps.microsoft.com/search?query=StepForge),
+1. Open [StepForge in the Microsoft Store](https://apps.microsoft.com/detail/9NDH81CF1HGV),
or open the **Microsoft Store** app from the Start menu and search for
**StepForge**.
2. Click **Get** (or **Install**).
diff --git a/docs/windows_installation.md b/docs/windows_installation.md
index 71ba59e2..e291cefb 100644
--- a/docs/windows_installation.md
+++ b/docs/windows_installation.md
@@ -11,7 +11,7 @@ to install it:
## Recommended: the Microsoft Store
-1. Open [StepForge in the Microsoft Store](https://apps.microsoft.com/search?query=StepForge),
+1. Open [StepForge in the Microsoft Store](https://apps.microsoft.com/detail/9NDH81CF1HGV),
or search for **StepForge** in the **Microsoft Store** app.
2. Click **Get**, then **Open**.
diff --git a/scripts/package-windows-store.js b/scripts/package-windows-store.js
index 8099e5a7..1900c4ca 100644
--- a/scripts/package-windows-store.js
+++ b/scripts/package-windows-store.js
@@ -1,9 +1,10 @@
#!/usr/bin/env node
'use strict';
-// Builds the Microsoft Store package (.appx) that is uploaded to Partner
-// Center. The Store signs it, so it is never signed here. Needs Windows (the
-// Windows SDK's makeappx), so it runs in CI; see docs/windows/MICROSOFT_STORE_RELEASE.md.
+// Builds the Microsoft Store package (.appx) that the release workflow submits
+// to Partner Center (scripts/publish-microsoft-store.js). The Store signs it,
+// so it is never signed here. Needs Windows (the Windows SDK's makeappx), so it
+// runs in CI.
//
// npm run package:windows:store # the identity in packaging/windows/store/identity.json
// node scripts/package-windows-store.js --if-configured # the same, skipped while identity.json is unfilled
@@ -19,6 +20,7 @@ const path = require('node:path');
const { build, Platform, Arch } = require('electron-builder');
const { APP_ID, BUILD_VERSION, createWindowsInstallerConfig } = require('./package-windows');
const { LANGUAGES } = require('../core/i18n');
+const { storeVersion } = require('./store-version');
const ROOT_DIR = path.resolve(__dirname, '..');
const STORE_DIR = path.join(ROOT_DIR, 'packaging', 'windows', 'store');
@@ -60,24 +62,6 @@ function validateStoreIdentity(identity) {
};
}
-/**
- * The Store version for a StepForge build version. The Store reserves the
- * fourth part and needs it to be 0, and every submission must be newer than
- * the last, so a.b.c.d becomes a.b.(c*100+d).0: 1.5.3 → 1.5.300.0 and
- * 1.5.3.1 → 1.5.301.0. Each part must fit in 16 bits.
- */
-function storeVersion(buildVersion) {
- const parts = String(buildVersion || '').split('.');
- if (parts.length < 1 || parts.length > 4 || !parts.every((part) => /^(0|[1-9]\d{0,4})$/.test(part))) {
- throw new Error(`Build version ${JSON.stringify(buildVersion)} is not a numeric version.`);
- }
- const [major, minor = 0, patch = 0, revision = 0] = parts.map(Number);
- if (revision > 99) throw new Error(`Build version ${buildVersion}: the fourth part must be below 100 for the Store version.`);
- const build = patch * 100 + revision;
- if ([major, minor, build].some((n) => n > 65535)) throw new Error(`Build version ${buildVersion} does not fit a Store version.`);
- return `${major}.${minor}.${build}.0`;
-}
-
/** The manifest template with the Store version filled in. */
function renderStoreManifest(version, template = fs.readFileSync(MANIFEST_TEMPLATE, 'utf8')) {
if (!/^\d+\.\d+\.\d+\.0$/.test(version)) throw new Error(`invalid Store version ${version}`);
diff --git a/scripts/publish-microsoft-store.js b/scripts/publish-microsoft-store.js
new file mode 100644
index 00000000..a0cbfcc1
--- /dev/null
+++ b/scripts/publish-microsoft-store.js
@@ -0,0 +1,212 @@
+#!/usr/bin/env node
+'use strict';
+
+// Submits a release's Microsoft Store package to Partner Center for
+// certification with the Microsoft Store Developer CLI (msstore), which the
+// release workflow installs. Once the package passes, the Store publishes it
+// and updates StepForge on users' PCs. Certification usually takes a few hours
+// and sometimes up to three days.
+//
+// node scripts/publish-microsoft-store.js
+//
+// Signs in to Partner Center as a Microsoft Entra ID app that has the Manager
+// role there, from MICROSOFT_STORE_TENANT_ID, MICROSOFT_STORE_SELLER_ID,
+// MICROSOFT_STORE_CLIENT_ID and MICROSOFT_STORE_CLIENT_SECRET. Dependency-free,
+// so the workflow job doesn't install the packaging tools.
+
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+
+const { storeVersion } = require('./store-version');
+
+const IDENTITY_FILE = path.join(__dirname, '..', 'packaging', 'windows', 'store', 'identity.json');
+
+const CREDENTIALS = [
+ ['MICROSOFT_STORE_TENANT_ID', '--tenantId'],
+ ['MICROSOFT_STORE_SELLER_ID', '--sellerId'],
+ ['MICROSOFT_STORE_CLIENT_ID', '--clientId'],
+ ['MICROSOFT_STORE_CLIENT_SECRET', '--clientSecret'],
+];
+
+// A submission with one of these statuses has been committed and is on its
+// way to, or already in, the Store. Anything else (a draft, or a failed or
+// canceled submission) can be replaced.
+const SUBMITTED = new Set(['CommitStarted', 'PreProcessing', 'Certification', 'Release', 'PendingPublication', 'Publishing', 'Published']);
+
+/** The build's Store package: its path, StepForge version and Store version. */
+function findStorePackage(dir) {
+ const found = fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => name.toLowerCase().endsWith('.appx')) : [];
+ if (found.length !== 1) throw new Error(`Expected exactly one Microsoft Store .appx in ${dir}, found ${found.length}.`);
+ // Named by package-windows-store.js: "StepForge Microsoft Store.appx".
+ const match = /^StepForge (\d+(?:\.\d+){0,3}) Microsoft Store\.appx$/.exec(found[0]);
+ if (!match) throw new Error(`Unexpected Microsoft Store package name: ${found[0]}`);
+ return { file: path.join(dir, found[0]), buildVersion: match[1], version: storeVersion(match[1]) };
+}
+
+/**
+ * The name the package is uploaded as. The CLI only takes .msix files, which
+ * are the same format as .appx, and only replaces an earlier package that has
+ * the same extension.
+ */
+function uploadName(version) {
+ return `StepForge_${version}_x64.msix`;
+}
+
+/** The Store ID from identity.json, which Settings → About uses too. */
+function readProductId(file = IDENTITY_FILE) {
+ const productId = String(JSON.parse(fs.readFileSync(file, 'utf8')).productId || '').trim();
+ if (!/^[0-9A-Z]{12}$/.test(productId)) {
+ throw new Error('packaging/windows/store/identity.json has no Store ID (productId); copy it from Partner Center → Product identity.');
+ }
+ return productId;
+}
+
+/** The msstore reconfigure arguments, or what to set up in the repository. */
+function reconfigureArgs(env = process.env) {
+ const missing = CREDENTIALS.map(([name]) => name).filter((name) => !String(env[name] || '').trim());
+ if (missing.length) {
+ throw new Error(`Set ${missing.join(', ')} to submit to the Microsoft Store: the client secret as a repository secret and the IDs as repository variables, from the Entra ID app that has the Manager role in Partner Center.`);
+ }
+ return ['reconfigure', ...CREDENTIALS.flatMap(([name, flag]) => [flag, env[name].trim()])];
+}
+
+// The CLI writes PascalCase names but reads any case, as the Partner Center
+// API does, so look names up without case.
+function prop(object, name) {
+ const key = object && Object.keys(object).find((k) => k.toLowerCase() === name.toLowerCase());
+ return key === undefined ? undefined : object[key];
+}
+
+function setProp(object, name, value) {
+ const key = Object.keys(object).find((k) => k.toLowerCase() === name.toLowerCase()) || name;
+ object[key] = value;
+}
+
+function packagesOf(submission) {
+ const packages = prop(submission, 'ApplicationPackages');
+ return Array.isArray(packages) ? packages : [];
+}
+
+/**
+ * Whether this Store version is already committed: a rerun of a release
+ * whose submission is in certification or live has nothing left to do.
+ */
+function isAlreadySubmitted(submission, version) {
+ return SUBMITTED.has(prop(submission, 'Status'))
+ && packagesOf(submission).some((pkg) => prop(pkg, 'Version') === version && prop(pkg, 'FileStatus') !== 'PendingDelete');
+}
+
+/**
+ * Leaves the new upload as the submission's only package. The Store offers
+ * the newest package anyway, but packages from earlier releases would pile up,
+ * and the CLI only removes one with the same extension (the first release was
+ * an .appx uploaded by hand).
+ */
+function retirePreviousPackages(submission, name) {
+ const packages = packagesOf(submission);
+ if (!packages.some((pkg) => prop(pkg, 'FileName') === name && prop(pkg, 'FileStatus') === 'PendingUpload')) {
+ throw new Error(`The draft submission has no ${name} waiting to be committed.`);
+ }
+ const retired = [];
+ const kept = packages.filter((pkg) => {
+ if (prop(pkg, 'FileName') === name) return true;
+ // Uploaded to a draft but never committed: there is nothing to delete.
+ if (prop(pkg, 'FileStatus') === 'PendingUpload') return false;
+ if (prop(pkg, 'FileStatus') !== 'PendingDelete') {
+ setProp(pkg, 'FileStatus', 'PendingDelete');
+ retired.push(prop(pkg, 'FileName'));
+ }
+ return true;
+ });
+ setProp(submission, 'ApplicationPackages', kept);
+ return retired;
+}
+
+/** Runs msstore; returns its standard output when asked to capture it. */
+function runMsstore(args, { capture = false } = {}) {
+ const result = spawnSync('msstore', args, {
+ stdio: ['ignore', capture ? 'pipe' : 'inherit', 'inherit'],
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ if (result.error) throw new Error(`Could not run msstore: ${result.error.message}`);
+ // reconfigure's arguments hold the client secret: name only the command.
+ if (result.status !== 0) throw new Error(`msstore ${args[0]}${args[0] === 'submission' ? ` ${args[1]}` : ''} failed with exit code ${result.status}.`);
+ return capture ? result.stdout : '';
+}
+
+function getSubmission(run, productId) {
+ const output = run(['submission', 'get', productId], { capture: true });
+ try {
+ return JSON.parse(output);
+ } catch {
+ throw new Error(`msstore submission get did not return JSON: ${output.slice(0, 200)}`);
+ }
+}
+
+async function publishToStore({ dir, env = process.env, run = runMsstore, identityFile = IDENTITY_FILE, log = console.log } = {}) {
+ const pkg = findStorePackage(dir);
+ const productId = readProductId(identityFile);
+ run(reconfigureArgs(env));
+
+ const current = getSubmission(run, productId);
+ if (isAlreadySubmitted(current, pkg.version)) {
+ log(`StepForge ${pkg.buildVersion} (Store version ${pkg.version}) is already submitted: the submission is ${prop(current, 'Status')}. Nothing to do.`);
+ return { submitted: false, version: pkg.version };
+ }
+
+ const name = uploadName(pkg.version);
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-store-submit-'));
+ try {
+ const upload = path.join(workDir, name);
+ fs.copyFileSync(pkg.file, upload);
+
+ // Replaces any draft with a copy of the live submission, uploads the
+ // package to it, and leaves it uncommitted so old packages can go first.
+ // StepForge is free. Saying so keeps the CLI from stopping when Partner
+ // Center reports per-market pricing it can't send back.
+ run(['publish', upload, '--appId', productId, '--priceId', 'Free', '--noCommit']);
+
+ const draft = getSubmission(run, productId);
+ const retired = retirePreviousPackages(draft, name);
+ if (retired.length) log(`Removing the previous package${retired.length > 1 ? 's' : ''}: ${retired.join(', ')}`);
+ const payload = path.join(workDir, 'submission.json');
+ fs.writeFileSync(payload, JSON.stringify(draft));
+ run(['submission', 'update', productId, '--payload', payload]);
+
+ run(['submission', 'publish', productId]);
+ // Waits for Partner Center to accept the submission, not for certification.
+ run(['submission', 'poll', productId]);
+ } finally {
+ fs.rmSync(workDir, { recursive: true, force: true });
+ }
+
+ log(`Submitted StepForge ${pkg.buildVersion} (Store version ${pkg.version}) for certification: https://partner.microsoft.com/dashboard/products/${productId}/submissions`);
+ return { submitted: true, version: pkg.version };
+}
+
+if (require.main === module) {
+ const dir = process.argv[2];
+ if (!dir) {
+ console.error('Usage: node scripts/publish-microsoft-store.js ');
+ process.exitCode = 2;
+ } else {
+ publishToStore({ dir: path.resolve(dir) }).catch((err) => {
+ console.error(err.message || err);
+ process.exitCode = 1;
+ });
+ }
+}
+
+module.exports = {
+ SUBMITTED,
+ findStorePackage,
+ uploadName,
+ readProductId,
+ reconfigureArgs,
+ isAlreadySubmitted,
+ retirePreviousPackages,
+ publishToStore,
+};
diff --git a/scripts/store-version.js b/scripts/store-version.js
new file mode 100644
index 00000000..73f375d5
--- /dev/null
+++ b/scripts/store-version.js
@@ -0,0 +1,24 @@
+'use strict';
+
+// Dependency-free, so the release workflow's Store submission job can use it
+// without installing the packaging tools.
+
+/**
+ * The Store version for a StepForge build version. The Store reserves the
+ * fourth part and needs it to be 0, and every submission must be newer than
+ * the last, so a.b.c.d becomes a.b.(c*100+d).0: 1.5.3 → 1.5.300.0 and
+ * 1.5.3.1 → 1.5.301.0. Each part must fit in 16 bits.
+ */
+function storeVersion(buildVersion) {
+ const parts = String(buildVersion || '').split('.');
+ if (parts.length < 1 || parts.length > 4 || !parts.every((part) => /^(0|[1-9]\d{0,4})$/.test(part))) {
+ throw new Error(`Build version ${JSON.stringify(buildVersion)} is not a numeric version.`);
+ }
+ const [major, minor = 0, patch = 0, revision = 0] = parts.map(Number);
+ if (revision > 99) throw new Error(`Build version ${buildVersion}: the fourth part must be below 100 for the Store version.`);
+ const build = patch * 100 + revision;
+ if ([major, minor, build].some((n) => n > 65535)) throw new Error(`Build version ${buildVersion} does not fit a Store version.`);
+ return `${major}.${minor}.${build}.0`;
+}
+
+module.exports = { storeVersion };
diff --git a/tests/unit/package-windows-store.test.js b/tests/unit/package-windows-store.test.js
index b09d3b88..29a83b5c 100644
--- a/tests/unit/package-windows-store.test.js
+++ b/tests/unit/package-windows-store.test.js
@@ -177,10 +177,16 @@ test('npm scripts and the release workflow build the Store package', () => {
const pkg = JSON.parse(read('package.json'));
assert.equal(pkg.scripts['package:windows:store'], 'node scripts/package-windows-store.js');
assert.equal(pkg.scripts['package:windows:store:test'], 'node scripts/package-windows-store.js --test-identity');
- const job = read('.github/workflows/release.yml').split(' build-windows:')[1].split('\n build-linux:')[0];
- // Not "npm run ... -- --flag": the Windows runners' PowerShell drops the "--".
- assert.match(job, /node scripts\/package-windows-store\.js --if-configured\n/);
- assert.match(job, /releases\/\*\.appx/);
+ const workflow = read('.github/workflows/release.yml');
+ // Its own job, so it builds alongside the installer instead of after it.
+ const job = workflow.split(' build-windows-store:')[1].split('\n build-linux:')[0];
+ assert.match(job, /^\n needs: authorize-release\n/);
+ assert.match(job, /node scripts\/stamp-version\.js/);
+ assert.match(job, /run: npm run package:windows:store\n/);
+ assert.match(job, /name: microsoft-store-package\n\s+path: releases\/\*\.appx\n\s+if-no-files-found: error/);
+ const installer = workflow.split(' build-windows:')[1].split('\n build-windows-store:')[0];
+ assert.doesNotMatch(installer, /package-windows-store|package:windows:store|\.appx/);
+ assert.match(workflow.split('\n release:\n')[1].split('\n runs-on:')[0], /\n - build-windows-store\n/);
const ci = read('.github/workflows/ci.yml');
assert.match(ci, /npm run package:windows:store:test\n/);
assert.doesNotMatch(`${job}\n${ci}`, /package:windows:store -- /);
diff --git a/tests/unit/publish-microsoft-store.test.js b/tests/unit/publish-microsoft-store.test.js
new file mode 100644
index 00000000..e62df06c
--- /dev/null
+++ b/tests/unit/publish-microsoft-store.test.js
@@ -0,0 +1,225 @@
+'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 {
+ findStorePackage,
+ uploadName,
+ readProductId,
+ reconfigureArgs,
+ isAlreadySubmitted,
+ retirePreviousPackages,
+ publishToStore,
+} = require('../../scripts/publish-microsoft-store');
+
+/**
+ * Submitting a release's Store package to Partner Center
+ * (scripts/publish-microsoft-store.js). msstore is replaced by a stand-in
+ * that records each command and answers "submission get" like the CLI does.
+ */
+
+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_SELLER_ID: '12345678',
+ MICROSOFT_STORE_CLIENT_ID: 'client-guid',
+ MICROSOFT_STORE_CLIENT_SECRET: 'client~secret',
+};
+
+// The live submission after the first release, which was uploaded by hand.
+const published = () => ({
+ Id: '1152921505700000001',
+ Status: 'Published',
+ Pricing: { PriceId: 'Free' },
+ ApplicationPackages: [
+ { FileName: 'StepForge 1.5.3 Microsoft Store.appx', FileStatus: 'Uploaded', Id: '1', Version: '1.5.300.0', Architecture: 'X64' },
+ ],
+});
+
+async function withPackage(name, fn) {
+ const dir = makeTmpDir('store-submit');
+ try {
+ fs.writeFileSync(path.join(dir, name), 'appx bytes');
+ return await fn(dir);
+ } finally {
+ rmrf(dir);
+ }
+}
+
+/** A stand-in for msstore. `afterPublish` is the draft "msstore publish --noCommit" leaves. */
+function fakeMsstore({ current = published(), afterPublish } = {}) {
+ const calls = [];
+ let submission = current;
+ const run = (args) => {
+ const call = { args };
+ calls.push(call);
+ if (args[0] === 'publish') {
+ call.uploaded = fs.readFileSync(args[1], 'utf8');
+ submission = afterPublish ?? {
+ ...published(),
+ Id: '1152921505700000002',
+ Status: 'PendingCommit',
+ ApplicationPackages: [...published().ApplicationPackages, { FileName: path.basename(args[1]), FileStatus: 'PendingUpload' }],
+ };
+ }
+ if (args[0] === 'submission' && args[1] === 'get') return JSON.stringify(submission);
+ if (args[0] === 'submission' && args[1] === 'update') call.payload = JSON.parse(fs.readFileSync(args[4], 'utf8'));
+ return '';
+ };
+ return { run, calls, commands: () => calls.map((c) => c.args.slice(0, c.args[0] === 'submission' ? 2 : 1).join(' ')) };
+}
+
+test('the Store package is found by the name the Store build gives it', async () => {
+ await withPackage('StepForge 1.6.0 Microsoft Store.appx', (dir) => {
+ assert.deepEqual(findStorePackage(dir), { file: path.join(dir, 'StepForge 1.6.0 Microsoft Store.appx'), buildVersion: '1.6.0', version: '1.6.0.0' });
+ });
+ await withPackage('StepForge 1.5.3.1 Microsoft Store.appx', (dir) => assert.equal(findStorePackage(dir).version, '1.5.301.0'));
+ await withPackage('StepForge Setup 1.6.0.exe', (dir) => assert.throws(() => findStorePackage(dir), /exactly one Microsoft Store \.appx/));
+ await withPackage('Other 1.6.0.appx', (dir) => assert.throws(() => findStorePackage(dir), /Unexpected Microsoft Store package name/));
+ await withPackage('StepForge 1.6.0 Microsoft Store.appx', (dir) => {
+ fs.writeFileSync(path.join(dir, 'StepForge 1.5.3 Microsoft Store.appx'), '');
+ assert.throws(() => findStorePackage(dir), /found 2/);
+ });
+ assert.throws(() => findStorePackage(path.join(root, 'no-such-folder')), /found 0/);
+ // package-windows-store.js names it so.
+ assert.match(read('scripts/package-windows-store.js'), /artifactName: '\$\{productName\} \$\{buildVersion\} Microsoft Store\.\$\{ext\}'/);
+});
+
+test('the package is uploaded as .msix, the only extension the CLI takes', () => {
+ assert.equal(uploadName('1.6.0.0'), 'StepForge_1.6.0.0_x64.msix');
+});
+
+test('the Store ID comes from identity.json', () => {
+ assert.match(readProductId(), /^9[0-9A-Z]{11}$/);
+ const dir = makeTmpDir('store-identity');
+ try {
+ const file = path.join(dir, 'identity.json');
+ fs.writeFileSync(file, JSON.stringify({ productId: '' }));
+ assert.throws(() => readProductId(file), /no Store ID/);
+ } finally {
+ rmrf(dir);
+ }
+});
+
+test('signing in needs all four Partner Center credentials and says which are missing', () => {
+ assert.deepEqual(reconfigureArgs(ENV), [
+ 'reconfigure', '--tenantId', 'tenant-guid', '--sellerId', '12345678', '--clientId', 'client-guid', '--clientSecret', 'client~secret',
+ ]);
+ assert.throws(() => reconfigureArgs({ ...ENV, MICROSOFT_STORE_CLIENT_SECRET: '' }), (err) => {
+ assert.match(err.message, /^Set MICROSOFT_STORE_CLIENT_SECRET to submit/);
+ return true;
+ });
+ assert.throws(() => reconfigureArgs({}), /MICROSOFT_STORE_TENANT_ID, MICROSOFT_STORE_SELLER_ID, MICROSOFT_STORE_CLIENT_ID, MICROSOFT_STORE_CLIENT_SECRET/);
+});
+
+test('a release replaces the live package and commits the submission', async () => {
+ await withPackage('StepForge 1.6.0 Microsoft Store.appx', async (dir) => {
+ const store = fakeMsstore();
+ const result = await publishToStore({ dir, env: ENV, run: store.run, log: () => {} });
+
+ assert.deepEqual(result, { submitted: true, version: '1.6.0.0' });
+ assert.deepEqual(store.commands(), ['reconfigure', 'submission get', 'publish', 'submission get', 'submission update', 'submission publish', 'submission poll']);
+ const productId = readProductId();
+ const publish = store.calls[2];
+ assert.equal(path.basename(publish.args[1]), 'StepForge_1.6.0.0_x64.msix');
+ assert.equal(publish.uploaded, 'appx bytes', 'the .appx is uploaded unchanged');
+ assert.deepEqual(publish.args.slice(2), ['--appId', productId, '--priceId', 'Free', '--noCommit']);
+ assert.deepEqual(store.calls[4].args.slice(2, 4), [productId, '--payload']);
+ assert.deepEqual(store.calls[4].payload.ApplicationPackages.map((p) => [p.FileName, p.FileStatus]), [
+ ['StepForge 1.5.3 Microsoft Store.appx', 'PendingDelete'],
+ ['StepForge_1.6.0.0_x64.msix', 'PendingUpload'],
+ ]);
+ // The rest of the submission goes back as the CLI gave it.
+ assert.deepEqual(store.calls[4].payload.Pricing, { PriceId: 'Free' });
+ assert.ok(!fs.existsSync(publish.args[1]), 'the renamed copy is cleaned up');
+ });
+});
+
+test('a rerun leaves a version that is in certification or live alone', async () => {
+ for (const Status of ['Certification', 'Published']) {
+ await withPackage('StepForge 1.5.3 Microsoft Store.appx', async (dir) => {
+ const store = fakeMsstore({ current: { ...published(), Status } });
+ const result = await publishToStore({ dir, env: ENV, run: store.run, log: () => {} });
+ assert.deepEqual(result, { submitted: false, version: '1.5.300.0' });
+ assert.deepEqual(store.commands(), ['reconfigure', 'submission get']);
+ });
+ }
+});
+
+test('a failed or uncommitted submission of the same version is submitted again', () => {
+ for (const Status of ['PendingCommit', 'CommitFailed', 'CertificationFailed', 'PreProcessingFailed', 'Canceled']) {
+ assert.equal(isAlreadySubmitted({ ...published(), Status }, '1.5.300.0'), false, Status);
+ }
+ assert.equal(isAlreadySubmitted(published(), '1.6.0.0'), false, 'a newer version');
+ // The CLI writes PascalCase, the Partner Center API camelCase.
+ assert.equal(isAlreadySubmitted({ status: 'Certification', applicationPackages: [{ version: '1.6.0.0', fileStatus: 'Uploaded' }] }, '1.6.0.0'), true);
+});
+
+test('only the new upload is left in the submission', () => {
+ const draft = {
+ Status: 'PendingCommit',
+ ApplicationPackages: [
+ { FileName: 'StepForge 1.5.3 Microsoft Store.appx', FileStatus: 'Uploaded' },
+ { FileName: 'StepForge_1.5.301.0_x64.msix', FileStatus: 'PendingDelete' },
+ { FileName: 'StepForge_1.5.302.0_x64.msix', FileStatus: 'PendingUpload' },
+ { FileName: 'StepForge_1.6.0.0_x64.msix', FileStatus: 'PendingUpload' },
+ ],
+ };
+ assert.deepEqual(retirePreviousPackages(draft, 'StepForge_1.6.0.0_x64.msix'), ['StepForge 1.5.3 Microsoft Store.appx']);
+ assert.deepEqual(draft.ApplicationPackages.map((p) => [p.FileName, p.FileStatus]), [
+ ['StepForge 1.5.3 Microsoft Store.appx', 'PendingDelete'],
+ ['StepForge_1.5.301.0_x64.msix', 'PendingDelete'],
+ ['StepForge_1.6.0.0_x64.msix', 'PendingUpload'],
+ ]);
+
+ const camel = { applicationPackages: [{ fileName: 'old.appx', fileStatus: 'Uploaded' }, { fileName: 'new.msix', fileStatus: 'PendingUpload' }] };
+ retirePreviousPackages(camel, 'new.msix');
+ assert.deepEqual(camel, { applicationPackages: [{ fileName: 'old.appx', fileStatus: 'PendingDelete' }, { fileName: 'new.msix', fileStatus: 'PendingUpload' }] });
+
+ assert.throws(() => retirePreviousPackages(published(), 'StepForge_1.6.0.0_x64.msix'), /no StepForge_1\.6\.0\.0_x64\.msix waiting/);
+});
+
+test('the submission stops before committing when the upload is missing from the draft', async () => {
+ await withPackage('StepForge 1.6.0 Microsoft Store.appx', async (dir) => {
+ const store = fakeMsstore({ afterPublish: { ...published(), Status: 'PendingCommit' } });
+ await assert.rejects(publishToStore({ dir, env: ENV, run: store.run, log: () => {} }), /no StepForge_1\.6\.0\.0_x64\.msix waiting/);
+ assert.ok(!store.commands().includes('submission publish'));
+ });
+});
+
+test('missing credentials fail before msstore runs', async () => {
+ await withPackage('StepForge 1.6.0 Microsoft Store.appx', async (dir) => {
+ const store = fakeMsstore();
+ await assert.rejects(publishToStore({ dir, env: {}, run: store.run, log: () => {} }), /^Error: Set MICROSOFT_STORE_TENANT_ID/);
+ assert.equal(store.calls.length, 0);
+ });
+});
+
+test('stable releases submit the Store package from its build artifact', () => {
+ const workflow = read('.github/workflows/release.yml');
+ const job = workflow.slice(workflow.indexOf(' publish-microsoft-store:'));
+ assert.ok(workflow.includes(' publish-microsoft-store:'));
+ assert.match(job, /needs: release\n/);
+ assert.match(job, /if: inputs\.prerelease == false\n/);
+ assert.match(job, /runs-on: windows-latest\n/);
+ assert.match(job, /name: microsoft-store-package\n\s+path: dist\/microsoft-store\n/);
+ assert.match(job, /uses: microsoft\/microsoft-store-apppublisher@v1\.\d+\n\s+with:\n\s+version: v0\.4\.\d+\n/);
+ for (const name of ['TENANT_ID', 'SELLER_ID', 'CLIENT_ID']) {
+ assert.match(job, new RegExp(`MICROSOFT_STORE_${name}: \\$\\{\\{ vars\\.MICROSOFT_STORE_${name} \\}\\}\\n`));
+ }
+ assert.match(job, /MICROSOFT_STORE_CLIENT_SECRET: \$\{\{ secrets\.MICROSOFT_STORE_CLIENT_SECRET \}\}\n/);
+ assert.match(job, /run: node scripts\/publish-microsoft-store\.js dist\/microsoft-store\n/);
+ // The job doesn't install dependencies, so the script mustn't need them.
+ assert.doesNotMatch(job, /npm ci/);
+ for (const file of ['scripts/publish-microsoft-store.js', 'scripts/store-version.js']) {
+ for (const [, dep] of read(file).matchAll(/require\('([^']+)'\)/g)) {
+ assert.ok(dep.startsWith('node:') || dep.startsWith('./'), `${file} requires ${dep}`);
+ }
+ }
+});
diff --git a/tests/unit/release-access.test.js b/tests/unit/release-access.test.js
index 408ad968..d72611cc 100644
--- a/tests/unit/release-access.test.js
+++ b/tests/unit/release-access.test.js
@@ -25,9 +25,9 @@ test('every release build requires authorization and only the publisher receives
const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/release.yml'), 'utf8');
assert.match(workflow, /permissions:\n contents: read/);
assert.match(workflow, /run: node scripts\/check-release-access\.js/);
- for (const job of ['build-windows', 'build-linux', 'build-fedora']) {
+ for (const job of ['build-windows', 'build-windows-store', 'build-linux', 'build-fedora']) {
assert.ok(workflow.includes(` ${job}:\n needs: authorize-release\n`));
}
assert.match(workflow, / release:\n permissions:\n contents: write/);
- assert.equal((workflow.match(/run: node scripts\/check-release-access\.js/g) || []).length, 8, 'every job rechecks the rerun actor');
+ assert.equal((workflow.match(/run: node scripts\/check-release-access\.js/g) || []).length, 10, 'every job rechecks the rerun actor');
});