diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62d9c08a6..a9b5fca75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -766,3 +766,124 @@ 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 + id: submit + 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 + + # 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: | + 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 f42cde69c..67b3cb65e 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 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 | +|---|---|---| +| `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 |