From b2b50ae628385065c01e5f0d5a83377f5d4baa97 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Fri, 30 Oct 2026 23:38:54 -0500 Subject: [PATCH 1/2] Build the Store package in parallel and submit it to Partner Center StepForge is live in the Microsoft Store (9NDH81CF1HGV), so: - The Release workflow builds the Store .appx in its own build-windows-store job, alongside the installer and Linux builds, instead of after the .exe in build-windows. The GitHub Release waits for it like the other packages. - A new publish-microsoft-store job submits stable releases to Partner Center with Microsoft's Store Developer CLI (scripts/publish-microsoft-store.js): it uploads the package as .msix, marks earlier packages for removal, commits the submission and waits for Partner Center to accept it. A rerun skips a version that is already in certification or live. - storeVersion moves to the dependency-free scripts/store-version.js so the submission job needs no npm ci. - Docs link to the Store listing instead of a search, drop the "coming in a couple of days" note, add the Get it from Microsoft badge to the README, and no longer point to the deleted MICROSOFT_STORE_RELEASE.md. --- .github/workflows/release.yml | 99 ++++++++- README.md | 4 +- docs/CONTRIBUTING.md | 4 +- docs/windows/microsoft-store.md | 6 +- docs/windows_installation.md | 2 +- scripts/package-windows-store.js | 26 +-- scripts/publish-microsoft-store.js | 210 +++++++++++++++++++ scripts/store-version.js | 24 +++ tests/unit/package-windows-store.test.js | 14 +- tests/unit/publish-microsoft-store.test.js | 225 +++++++++++++++++++++ tests/unit/release-access.test.js | 4 +- 11 files changed, 573 insertions(+), 45 deletions(-) create mode 100644 scripts/publish-microsoft-store.js create mode 100644 scripts/store-version.js create mode 100644 tests/unit/publish-microsoft-store.test.js 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**. +Get it from Microsoft +
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..f00bcffd --- /dev/null +++ b/scripts/publish-microsoft-store.js @@ -0,0 +1,210 @@ +#!/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. + run(['publish', upload, '--appId', productId, '--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..8080372a --- /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, '--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'); }); From e480d67594eea0ad5c29ea53fa7ad1908b13cef0 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 31 Oct 2026 00:12:51 -0500 Subject: [PATCH 2/2] Submit the Store package with the Free price tier --- scripts/publish-microsoft-store.js | 4 +++- tests/unit/publish-microsoft-store.test.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/publish-microsoft-store.js b/scripts/publish-microsoft-store.js index f00bcffd..a0cbfcc1 100644 --- a/scripts/publish-microsoft-store.js +++ b/scripts/publish-microsoft-store.js @@ -165,7 +165,9 @@ async function publishToStore({ dir, env = process.env, run = runMsstore, identi // 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. - run(['publish', upload, '--appId', productId, '--noCommit']); + // 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); diff --git a/tests/unit/publish-microsoft-store.test.js b/tests/unit/publish-microsoft-store.test.js index 8080372a..e62df06c 100644 --- a/tests/unit/publish-microsoft-store.test.js +++ b/tests/unit/publish-microsoft-store.test.js @@ -129,7 +129,7 @@ test('a release replaces the live package and commits the submission', async () 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, '--noCommit']); + 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'],