From 4ab8d9c5c15ccc3a0b30d72d96adc7a96bd70bf2 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 8 Aug 2026 18:33:46 +0200 Subject: [PATCH 1/2] ci(store): submit the appx to the Microsoft Store from the release build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading the Store package was the last manual step of a release, and it has already cost one blocked submission: 1.8.0 ended up with two different packages under a single version because the artifact was downloaded twice and both copies uploaded, which Partner Center rejects outright. The job runs on stable tags only. An RC reaching the Store would go through certification and land on every user's machine as an automatic update, so `!contains(tag, '-')` is what separates v1.9.1 from v1.9.1-rc.2. It lives in build.yml rather than beside publish-winget.yml because the appx never becomes a release asset — the GitHub release carries only the NSIS installer, so `release: published` has nothing to hand a downstream workflow. The package exists solely as this run's artifact. Gated on the credentials existing, like the macOS signing job: absent, it warns and skips, leaving the manual upload path intact; partially configured, it fails rather than silently shipping nothing while the release looks complete. `vars.MSSTORE_PRODUCT_ID` gates the job itself so a fork cannot publish to our listing. This removes the upload, not the certification: every submission still waits for Microsoft to validate it. Microsoft also restricts automated updates to free products, which OpenScreen is. --- .github/workflows/build.yml | 97 +++++++++++++++++++ .../engineering/release-and-secrets.md | 29 ++++++ 2 files changed, 126 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62d9c08a6..d0082d57f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -766,3 +766,100 @@ jobs: exit 1 ;; esac + + # Publishing the Store package is the last manual step in the release: the appx + # had to be downloaded from this run's artifacts and uploaded by hand in Partner + # Center. That is also how 1.8.0 ended up with two different packages under one + # version — the artifact was downloaded twice and both copies were uploaded. + # + # This job lives in build.yml rather than beside publish-winget.yml because the + # appx never becomes a release asset: the GitHub release carries only the NSIS + # installer, so `release: published` has nothing to hand a downstream workflow. + # The package exists solely as this run's artifact. + publish-msstore: + name: Publish to Microsoft Store + runs-on: windows-latest + needs: + - build-windows-store + - publish-release + # Stable tags only, and never a fork: an RC reaching the Store would go through + # certification and land on every user's machine as an automatic update. + # `!contains(…, '-')` is what separates v1.9.1 from v1.9.1-rc.2. + if: >- + ${{ vars.MSSTORE_PRODUCT_ID != '' + && ((github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref_name, '-')) + || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '' && !contains(github.event.inputs.release_tag, '-'))) }} + steps: + # Same all-or-nothing gate as the macOS signing job: a half-configured + # publisher is a misnamed secret, and the quiet failure mode — shipping + # nothing while the release looks complete — is the one worth making loud. + - name: Resolve Store credentials + id: store + shell: bash + env: + AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + SELLER_ID: ${{ secrets.SELLER_ID }} + run: | + required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID + AZURE_AD_APPLICATION_SECRET SELLER_ID) + missing=() + for name in "${required[@]}"; do + [[ -n "${!name}" ]] || missing+=("$name") + done + + if [[ ${#missing[@]} -eq 0 ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Store credentials present — the package will be submitted." + elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::No Store credentials configured; upload the appx by hand in Partner Center." + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::error::Store publishing is partially configured; missing: ${missing[*]}" + exit 1 + fi + + - name: Download Store package + if: steps.store.outputs.enabled == 'true' + uses: actions/download-artifact@v4 + with: + name: openscreen-windows-store + path: artifacts/store + + - name: Configure Microsoft Store CLI + if: steps.store.outputs.enabled == 'true' + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Submit the package to the Store + if: steps.store.outputs.enabled == 'true' + shell: pwsh + env: + PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} + run: | + msstore reconfigure ` + --tenantId ${{ secrets.AZURE_AD_TENANT_ID }} ` + --sellerId ${{ secrets.SELLER_ID }} ` + --clientId ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} ` + --clientSecret ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + + # Resolve the appx rather than hardcode its name: artifactName carries the + # version, and download-artifact keeps the release// prefix. + $appx = Get-ChildItem artifacts/store -Recurse -Filter '*.appx' | Select-Object -First 1 + if (-not $appx) { throw 'no .appx in the downloaded artifact' } + if (@(Get-ChildItem artifacts/store -Recurse -Filter '*.appx').Count -ne 1) { + throw 'more than one .appx in the artifact — refusing to guess which one to submit' + } + Write-Output "Submitting $($appx.Name) to product $env:PRODUCT_ID" + msstore publish $appx.FullName -id $env:PRODUCT_ID + + - name: Summary + if: always() + shell: bash + run: | + if [[ "${{ steps.store.outputs.enabled }}" == "true" ]]; then + echo "Submitted to the Store. Certification still has to pass before it goes live." >> "$GITHUB_STEP_SUMMARY" + else + echo "Store publishing skipped (no credentials). Upload the appx from this run's artifacts by hand." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index f42cde69c..b104b01fe 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -86,6 +86,35 @@ The certificate account needs Developer ID signing capability, and the Apple acc Rotate the certificate by exporting a replacement P12, base64-encoding it without line-wrap changes, updating the P12/password/name secrets together, testing a stable-format manual build, then revoking the old certificate if required. Rotate the app-specific password in Apple ID settings, replace `APPLE_APP_SPECIFIC_PASSWORD`, verify notarization, and revoke the old password. `APPLE_ID` and `APPLE_TEAM_ID` normally change only when the owning account or team changes. +## Microsoft Store publishing + +`build.yml`'s `publish-msstore` job submits the appx to the Store through the [Microsoft Store Developer CLI](https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/github-actions). It runs on **stable tags only** — an RC reaching the Store would go through certification and land on every user's machine as an automatic update. + +| Name | Kind | Purpose | +|---|---|---| +| `MSSTORE_PRODUCT_ID` | Variable | Store product ID (`9MXQ1HQJL5G5`). Gates the whole job, so a fork never publishes to our listing. | +| `AZURE_AD_TENANT_ID` | Secret | Entra tenant associated with the Partner Center account. | +| `AZURE_AD_APPLICATION_CLIENT_ID` | Secret | Application (client) ID of the Entra app registration. | +| `AZURE_AD_APPLICATION_SECRET` | Secret | Client secret of that registration. The only real credential here. | +| `SELLER_ID` | Secret | Publisher/Seller ID from Partner Center account settings. | + +With none of them set the job warns and skips, leaving the appx to be uploaded by hand from the run's artifacts; with some but not all it fails, on the same reasoning as the Apple path. + +One-time setup, in order — each step depends on the previous one: + +1. Associate an Entra tenant with the Partner Center account. +2. Register an application in Entra ID and create a client secret for it. +3. In Partner Center, under **Account settings → User management → Microsoft Entra applications**, add that application and give it the **Manager** role. Tenant and client IDs alone are not enough; the failure is an authorization error at submit time. +4. Set the four secrets and the variable. + +Note what this does and does not remove. It removes the manual upload — which is worth having in itself: 1.8.0 ended up with two different packages under one version because the artifact was downloaded twice and both copies uploaded, and Partner Center rejects that outright. It does **not** remove certification: every submission still waits for Microsoft to validate it, and the update only goes live afterwards. + +Two constraints from Microsoft's documentation: automated updates through GitHub Actions are supported **for free products only**, and the app must already be published and live in the Store — the API cannot create a listing, only submit to an existing one. + +`msstore submission updateMetadata` can also drive the Store listing text from a versioned `metadata.json`, which would replace the CSV export/import round-trip. Not wired up here. + +Rotate by issuing a new client secret on the Entra registration, updating `AZURE_AD_APPLICATION_SECRET`, publishing one release to confirm, then deleting the old secret. The tenant, client and seller IDs change only when the registration or account does. + ## Discord secrets and variables | Name | Kind | Used for | From 966c93ce42eed3e8f5a15732c9b871d8c1ef9cb6 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 8 Aug 2026 22:20:59 +0200 Subject: [PATCH 2/2] fix(store): report the actual submission result, not the configuration The summary keyed off `enabled` alone under always(), so it claimed "Submitted to the Store" when `msstore publish` had failed, and "skipped, no credentials" when the resolver had rejected a half-configured one. A release summary asserting success over a package that never left the runner is the exact failure mode this job exists to remove. It now reads the resolver and submit step outcomes and distinguishes four states: submitted, skipped for want of credentials, half-configured, and submission failed. Values reach the script through env rather than expanding into shell source. Docs: the job also runs for a workflow_dispatch carrying a stable release_tag, not only for pushed tags. --- .github/workflows/build.yml | 34 ++++++++++++++++--- .../engineering/release-and-secrets.md | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0082d57f..a9b5fca75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -833,6 +833,7 @@ jobs: uses: microsoft/microsoft-store-apppublisher@v1.1 - name: Submit the package to the Store + id: submit if: steps.store.outputs.enabled == 'true' shell: pwsh env: @@ -854,12 +855,35 @@ jobs: Write-Output "Submitting $($appx.Name) to product $env:PRODUCT_ID" msstore publish $appx.FullName -id $env:PRODUCT_ID + # Report what happened, not what was configured. Keyed off `enabled` alone + # under always(), this claimed "Submitted to the Store" when `msstore + # publish` had failed, and "skipped, no credentials" when the resolver had + # rejected a half-configured one — a release summary asserting success over + # a package that never left the runner, which is the failure mode this whole + # job exists to remove. + # + # Values go through env rather than being interpolated into the script: the + # outcome of a step is not attacker-controlled here, but `${{ }}` expanding + # straight into shell source is the habit worth not having. - name: Summary if: always() shell: bash + env: + RESOLVE: ${{ steps.store.outcome }} + ENABLED: ${{ steps.store.outputs.enabled }} + SUBMIT: ${{ steps.submit.outcome }} run: | - if [[ "${{ steps.store.outputs.enabled }}" == "true" ]]; then - echo "Submitted to the Store. Certification still has to pass before it goes live." >> "$GITHUB_STEP_SUMMARY" - else - echo "Store publishing skipped (no credentials). Upload the appx from this run's artifacts by hand." >> "$GITHUB_STEP_SUMMARY" - fi + case "${RESOLVE}:${ENABLED}:${SUBMIT}" in + success:true:success) + echo "Submitted to the Store. Certification still has to pass before it goes live." + ;; + success:false:*) + echo "Store publishing skipped: no credentials configured. Upload the appx from this run's artifacts by hand." + ;; + failure:*) + echo "Store credentials are half-configured, so nothing was submitted. The job log names the missing secrets." + ;; + *) + echo "Store submission did not complete (submit step: ${SUBMIT:-not reached}). The package is still attached to this run as an artifact." + ;; + esac >> "$GITHUB_STEP_SUMMARY" diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index b104b01fe..67b3cb65e 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -88,7 +88,7 @@ Rotate the certificate by exporting a replacement P12, base64-encoding it withou ## Microsoft Store publishing -`build.yml`'s `publish-msstore` job submits the appx to the Store through the [Microsoft Store Developer CLI](https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/github-actions). It runs on **stable tags only** — an RC reaching the Store would go through certification and land on every user's machine as an automatic update. +`build.yml`'s `publish-msstore` job submits the appx to the Store through the [Microsoft Store Developer CLI](https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/github-actions). It runs for **stable versions only**, whether that comes from a pushed `vX.Y.Z` tag or from a manual `workflow_dispatch` whose `release_tag` is a stable one. An RC is excluded either way: it would go through certification and land on every user's machine as an automatic update. | Name | Kind | Purpose | |---|---|---|