From 5e090366ba52a6a90c9443301068991c5e8192bd Mon Sep 17 00:00:00 2001 From: kellylford Date: Thu, 22 Oct 2026 08:46:19 -0500 Subject: [PATCH 1/2] Submit the Store bundle through the Microsoft Store submission API store-submit.yml uploads a release run's msixbundle with the Microsoft Store Developer CLI, sets What's new from the version's CHANGELOG section, and commits it for certification. release.yml calls it after the Windows job on a tag that ships Windows; it can also be run by hand for an earlier run's bundle. Until the PARTNER_CENTER_* secrets exist it skips with a notice. docs/STORE-PLAN.md has the one-time Partner Center setup. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 16 ++++ .github/workflows/store-submit.yml | 142 +++++++++++++++++++++++++++++ WORKFLOW.md | 2 + docs/STORE-PLAN.md | 34 ++++++- 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/store-submit.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41c31ad..6f9dca4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,10 +27,12 @@ jobs: contents: write outputs: ship: ${{ steps.read.outputs.ship }} + version: ${{ steps.read.outputs.version }} steps: - uses: actions/checkout@v7 - id: read run: | + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" if [[ "$GITHUB_REF" == refs/tags/v* ]]; then ship="$(build/release-platforms.sh ships windows)"; else ship=true; fi echo "ship=$ship" >> "$GITHUB_OUTPUT" [ "$ship" = true ] || echo "::notice::This release does not ship on Windows (RELEASE-PLATFORMS), so nothing is built here." @@ -250,3 +252,17 @@ jobs: with: name: rssquick-microsoft-store path: artifacts/*.msixbundle + + # Submit the Store bundle to Partner Center for certification. Skips with a notice until the + # PARTNER_CENTER_* secrets exist; store-submit.yml says how to make them. + store: + needs: [notes, package] + if: startsWith(github.ref, 'refs/tags/v') && needs.notes.outputs.ship == 'true' + permissions: + contents: read + actions: read + uses: ./.github/workflows/store-submit.yml + with: + run_id: ${{ github.run_id }} + version: ${{ needs.notes.outputs.version }} + secrets: inherit diff --git a/.github/workflows/store-submit.yml b/.github/workflows/store-submit.yml new file mode 100644 index 0000000..49757f4 --- /dev/null +++ b/.github/workflows/store-submit.yml @@ -0,0 +1,142 @@ +name: Microsoft Store Submission + +# Submits the Store bundle a release run built to Partner Center, and commits it for +# certification, through the Microsoft Store Developer CLI (msstore), which calls the Store +# submission API. Certification then takes hours to days; Partner Center emails when it is done. +# +# release.yml calls this after the Windows package job on every tag that ships Windows. It can +# also be run by hand from the Actions tab with the id of a release run, which is how a bundle +# from before this workflow existed gets submitted. +# +# Repository secrets (one-time setup, docs/STORE-PLAN.md "Submitting through the API"): +# PARTNER_CENTER_TENANT_ID, PARTNER_CENTER_CLIENT_ID, PARTNER_CENTER_CLIENT_SECRET +# The Microsoft Entra application added to Partner Center with the Manager role, and a key. +# PARTNER_CENTER_SELLER_ID +# Partner Center, Account settings, Legal info: the Seller ID. +# Until they are all set, this skips with a notice rather than failing the release. + +on: + workflow_call: + inputs: + run_id: + type: string + required: true + version: + type: string + required: true + secrets: + PARTNER_CENTER_TENANT_ID: + required: false + PARTNER_CENTER_SELLER_ID: + required: false + PARTNER_CENTER_CLIENT_ID: + required: false + PARTNER_CENTER_CLIENT_SECRET: + required: false + workflow_dispatch: + inputs: + run_id: + description: 'The id of the release run whose rssquick-microsoft-store bundle to submit' + type: string + required: true + version: + description: 'The version in that bundle, such as 1.4.1 (its CHANGELOG section becomes "What''s new")' + type: string + required: true + +permissions: + contents: read + actions: read # download the bundle from another run + +# One submission at a time: a second would replace the first's draft. +concurrency: + group: store-submit + cancel-in-progress: false + +jobs: + submit: + runs-on: windows-latest + env: + PRODUCT_ID: 9NFGQ354JDG5 + TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }} + SELLER_ID: ${{ secrets.PARTNER_CENTER_SELLER_ID }} + CLIENT_ID: ${{ secrets.PARTNER_CENTER_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }} + steps: + - name: Check the Partner Center credentials + id: creds + shell: pwsh + run: | + $missing = @('TENANT_ID', 'SELLER_ID', 'CLIENT_ID', 'CLIENT_SECRET') | Where-Object { -not [Environment]::GetEnvironmentVariable($_) } + if ($missing) { + Write-Host "::notice::Not submitted to the Microsoft Store: the PARTNER_CENTER_* secrets are not all set ($($missing -join ', ')). Upload the rssquick-microsoft-store bundle by hand, or see docs/STORE-PLAN.md." + "ready=false" >> $env:GITHUB_OUTPUT + } else { + "ready=true" >> $env:GITHUB_OUTPUT + } + + - uses: actions/checkout@v7 + if: steps.creds.outputs.ready == 'true' + + - name: Download the Store bundle + if: steps.creds.outputs.ready == 'true' + uses: actions/download-artifact@v7 + with: + name: rssquick-microsoft-store + run-id: ${{ inputs.run_id }} + github-token: ${{ github.token }} + path: store + + - name: Set up the Microsoft Store Developer CLI + if: steps.creds.outputs.ready == 'true' + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Sign in to Partner Center + if: steps.creds.outputs.ready == 'true' + shell: pwsh + run: msstore reconfigure --tenantId $env:TENANT_ID --sellerId $env:SELLER_ID --clientId $env:CLIENT_ID --clientSecret $env:CLIENT_SECRET + + # Upload into a draft first, so "What's new in this version" can be set before it goes to + # certification. publish recreates the draft from the last published submission, so it has + # to come before the metadata edit, not after. + - name: Upload the bundle + if: steps.creds.outputs.ready == 'true' + shell: pwsh + run: | + $bundle = Get-ChildItem store -Filter *.msixbundle | Select-Object -First 1 + if (-not $bundle) { throw 'The rssquick-microsoft-store artifact holds no .msixbundle.' } + if ($bundle.Name -notlike "*-${{ inputs.version }}.msixbundle") { + throw "The bundle is $($bundle.Name), not version ${{ inputs.version }}." + } + msstore publish store -i $bundle.FullName -id $env:PRODUCT_ID --noCommit + if ($LASTEXITCODE) { throw "msstore publish failed ($LASTEXITCODE)." } + + # The version's CHANGELOG section, as plain text, minus its heading. The Store shows it under + # "What's new in this version", for every language the listing has. + - name: Set "What's new in this version" + if: steps.creds.outputs.ready == 'true' + shell: pwsh + run: | + $version = '${{ inputs.version }}' + $lines = Get-Content CHANGELOG.md + $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no [$version] section." } + $section = @() + foreach ($l in $lines[($start + 1)..($lines.Count - 1)]) { if ($l.StartsWith('## [')) { break }; $section += $l } + # "1.4.1 is a Windows-only release. The Mac..." means nothing in the Store, which only has Windows. + $section = $section | Where-Object { $_ -notmatch '-only release|is a release for .* only|have not changed,|has not changed,|and stay at \d' } + $notes = (($section -join "`n") -replace '(?m)^#+ ', '' -replace '\*\*', '' -replace '`', '' -replace '\n +', ' ').Trim() + if ($notes.Length -gt 1500) { $notes = $notes.Substring(0, 1497) + '...' } + + $submission = msstore submission get $env:PRODUCT_ID | Out-String | ConvertFrom-Json + foreach ($listing in $submission.Listings.PSObject.Properties) { $listing.Value.BaseListing.ReleaseNotes = $notes } + msstore submission update $env:PRODUCT_ID ($submission | ConvertTo-Json -Depth 100 -Compress) + if ($LASTEXITCODE) { throw "msstore submission update failed ($LASTEXITCODE)." } + + - name: Commit the submission for certification + if: steps.creds.outputs.ready == 'true' + shell: pwsh + run: | + msstore submission publish $env:PRODUCT_ID + if ($LASTEXITCODE) { throw "msstore submission publish failed ($LASTEXITCODE)." } + msstore submission status $env:PRODUCT_ID diff --git a/WORKFLOW.md b/WORKFLOW.md index 2272302..7f13d60 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -29,6 +29,8 @@ Then: Pushing the tag runs three workflows, each for the platforms `RELEASE-PLATFORMS` lists; a workflow for a platform it leaves out stops at its first job and builds nothing. `.github/workflows/release.yml` checks the tag against `VERSION`, runs the tests, rebuilds all four Windows artefacts on a clean runner, and opens a **draft** GitHub release with them attached. `.github/workflows/macos-release.yml` does the same for the disk image on a macOS runner and attaches it to that same draft. `.github/workflows/ios-release.yml` builds the iPhone and iPad version at the same version, uploads it to TestFlight, and submits it to the External TestFlight group (the external testers, with the public link) for Beta App Review; unlike the draft, that goes to testers as soon as Apple approves it. See ios/README.md. +After the Windows job, `release.yml` submits the Microsoft Store bundle to Partner Center for certification through `store-submit.yml`, once its credentials exist (docs/STORE-PLAN.md, *Submitting through the API*); certification takes hours to days, so the Store copy trails the GitHub release. + `release.yml`'s first job creates the draft on every tag, whichever platforms it is for, and opens its notes with a line such as **"RSS Quick 1.4.1 is a Windows-only release. The Mac, iPhone and iPad versions have not changed, so there is nothing new to install there."** (`build/release-platforms.sh` writes it; a release on all three has no such line). The platform jobs then attach their files to it. The macOS job spends most of its time waiting on Apple's notary service, so by the time it uploads, the draft exists. The draft is made only once: re-running the job, or re-tagging, leaves notes you have edited alone. A fix for iPhone and iPad alone makes no GitHub release, because there would be nothing on it to download; the changelog records it. When you publish a release that has no Windows files, untick **Set as the latest release**. The Releases page, which the README sends Windows and Mac readers to, shows the latest release first, and a Mac-only release there tells a Windows reader there is nothing for them. It also keeps installed Windows copies on track whichever way Velopack picks its release; the portable Windows copy and the Mac look through recent releases for one with their own download, so they are not affected either way. diff --git a/docs/STORE-PLAN.md b/docs/STORE-PLAN.md index eb540be..46443ed 100644 --- a/docs/STORE-PLAN.md +++ b/docs/STORE-PLAN.md @@ -4,7 +4,8 @@ **Status:** live. 1.4.0 passed certification and was published on 2026-10-02: https://apps.microsoft.com/detail/9nfgq354jdg5?hl=en-US&gl=US. It is the same 1.4.0 released on GitHub and TestFlight on 2026-10-01, packaged from the `v1.4.0` tag. From the next version on, a `v*` tag's -release workflow builds the Store bundle too; uploading it to a new submission is by hand. +release workflow builds the Store bundle too, and `store-submit.yml` submits it through the Store +submission API once the Partner Center credentials are in place (below); until then, by hand. The Partner Center product is **RSSQuick**, an MSIX or PWA app, Store ID **9NFGQ354JDG5**, and its identity is in `build/store/identity.json`. @@ -147,8 +148,35 @@ values, so a release cannot quietly produce a bundle Partner Center would reject `identity.json` still holds placeholders, a tag skips the bundle with a warning instead, so the GitHub release is never held up by the Store. -The first submission is by hand, to read the certification report. Automating submission through -the Store submission API can come after, if uploading by hand becomes a chore. +The first submission was by hand, to read the certification report, which the API requires anyway: +it can only update a product that has been published once. + +### Submitting through the API + +`.github/workflows/store-submit.yml` uses the Microsoft Store Developer CLI (`msstore`), which +calls the Store submission API. `release.yml` runs it after the Windows job on every tag that ships +Windows: it downloads that run's bundle, uploads it into a draft submission, sets "What's new in +this version" from the version's CHANGELOG section (without the line about other platforms, which +means nothing in the Store), and commits it for certification. Run it by hand from the Actions tab +with a release run's id and version to submit a bundle built earlier. Without the credentials it +skips with a notice, and the bundle is still an artifact of the release run to upload by hand. + +The credentials, made once in Partner Center: + +1. **Account settings → Tenants**: the Partner Center account must have a Microsoft Entra tenant. + Associate the existing one (the tenant Azure Artifact Signing already uses), or create one. +2. **Account settings → User management → Microsoft Entra applications → Add Microsoft Entra + application**. Create a new one (or pick an existing app registration) and give it the + **Manager** role. +3. Open the application there: copy the **Tenant ID** and **Client ID**, then **Add new key** and + copy the **Key**. It is shown once. +4. **Account settings → Legal info** (Developer tab): copy the **Seller ID**. +5. Store them as repository secrets, from the repository folder, pasting each value when asked: + `gh secret set PARTNER_CENTER_TENANT_ID`, `gh secret set PARTNER_CENTER_CLIENT_ID`, + `gh secret set PARTNER_CENTER_CLIENT_SECRET`, `gh secret set PARTNER_CENTER_SELLER_ID`. + +The key expires (Partner Center offers one or two years). When it does, the job fails at +"Sign in to Partner Center": add a new key and replace `PARTNER_CENTER_CLIENT_SECRET`. ## Part 5 — Order of work From 78bde6b429c54c7cb929ea522d58cc2a7b2756a3 Mon Sep 17 00:00:00 2001 From: kellylford Date: Thu, 22 Oct 2026 08:54:07 -0500 Subject: [PATCH 2/2] Submit to the Store when the release is published, and fix the review's findings - Runs on release: published (and by hand with a tag), not on the tag push, so the Store waits for the same screen reader check the GitHub draft does. It finds the release.yml run that built the tag; release.yml no longer calls it. A release that doesn't ship on Windows is skipped. - msstore publish takes the bundle as its path argument; -i is a directory, so the old call could never upload. - msstore pinned to v0.4.3 (earlier versions wrapped their JSON output) and the setup action to v1.4 (node24). - The submission JSON goes through --payload, not the command line. - Everything that can fail is checked before the upload: the tag, the release run, and the What's new text, which is now the CHANGELOG section from its first ### heading, without links, cut at a whole line. - Exit codes checked after submission get; no listing is an error; inputs reach scripts through env. - STORE-PLAN.md, WORKFLOW.md and CLAUDE.md describe it, including that a submission still in certification blocks the next one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 19 +-- .github/workflows/store-submit.yml | 181 ++++++++++++++++++----------- CLAUDE.md | 2 +- WORKFLOW.md | 2 +- docs/STORE-PLAN.md | 20 +++- 5 files changed, 130 insertions(+), 94 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f9dca4..65acbd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,12 +27,10 @@ jobs: contents: write outputs: ship: ${{ steps.read.outputs.ship }} - version: ${{ steps.read.outputs.version }} steps: - uses: actions/checkout@v7 - id: read run: | - echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" if [[ "$GITHUB_REF" == refs/tags/v* ]]; then ship="$(build/release-platforms.sh ships windows)"; else ship=true; fi echo "ship=$ship" >> "$GITHUB_OUTPUT" [ "$ship" = true ] || echo "::notice::This release does not ship on Windows (RELEASE-PLATFORMS), so nothing is built here." @@ -228,7 +226,8 @@ jobs: artifacts/releases/RELEASES artifacts/releases/RELEASES-win-arm64 - # The Microsoft Store bundle, for uploading to Partner Center by hand. After the draft + # The Microsoft Store bundle. store-submit.yml submits it to Partner Center when the release + # is published, or it can be uploaded by hand. After the draft # release, so a problem here cannot hold up the GitHub downloads, and never attached to the # release: it is unsigned until the Store signs it, so it would install for nobody. # On a tag it must carry the real Partner Center identity; until build/store/identity.json @@ -252,17 +251,3 @@ jobs: with: name: rssquick-microsoft-store path: artifacts/*.msixbundle - - # Submit the Store bundle to Partner Center for certification. Skips with a notice until the - # PARTNER_CENTER_* secrets exist; store-submit.yml says how to make them. - store: - needs: [notes, package] - if: startsWith(github.ref, 'refs/tags/v') && needs.notes.outputs.ship == 'true' - permissions: - contents: read - actions: read - uses: ./.github/workflows/store-submit.yml - with: - run_id: ${{ github.run_id }} - version: ${{ needs.notes.outputs.version }} - secrets: inherit diff --git a/.github/workflows/store-submit.yml b/.github/workflows/store-submit.yml index 49757f4..cded1f8 100644 --- a/.github/workflows/store-submit.yml +++ b/.github/workflows/store-submit.yml @@ -1,52 +1,42 @@ name: Microsoft Store Submission -# Submits the Store bundle a release run built to Partner Center, and commits it for -# certification, through the Microsoft Store Developer CLI (msstore), which calls the Store -# submission API. Certification then takes hours to days; Partner Center emails when it is done. +# Submits a release's Microsoft Store bundle to Partner Center and commits it for certification, +# through the Microsoft Store Developer CLI (msstore), which calls the Store submission API. +# Certification then takes hours to days, and the Store copy goes live when it passes. # -# release.yml calls this after the Windows package job on every tag that ships Windows. It can -# also be run by hand from the Actions tab with the id of a release run, which is how a bundle -# from before this workflow existed gets submitted. +# It runs when a GitHub release is published, not when the tag is pushed. release.yml makes a +# draft on purpose, so the build is checked with a screen reader before anyone gets it; the Store +# waits for the same check. It finds the release.yml run that built the tag and submits that run's +# rssquick-microsoft-store bundle. A release that does not ship on Windows (RELEASE-PLATFORMS) is +# skipped. It can also be run by hand from the Actions tab with a tag. +# +# A release published by the workflow's own GITHUB_TOKEN would not start this, but releases are +# published by a person (or with their gh token), which does. +# +# A submission already in certification blocks a new one: msstore replaces a pending submission +# by deleting it, which Partner Center refuses while it is being certified. Wait for it to finish, +# or cancel it in Partner Center, then run this again by hand. # # Repository secrets (one-time setup, docs/STORE-PLAN.md "Submitting through the API"): # PARTNER_CENTER_TENANT_ID, PARTNER_CENTER_CLIENT_ID, PARTNER_CENTER_CLIENT_SECRET # The Microsoft Entra application added to Partner Center with the Manager role, and a key. # PARTNER_CENTER_SELLER_ID # Partner Center, Account settings, Legal info: the Seller ID. -# Until they are all set, this skips with a notice rather than failing the release. +# Until they are all set, this skips with a notice. on: - workflow_call: - inputs: - run_id: - type: string - required: true - version: - type: string - required: true - secrets: - PARTNER_CENTER_TENANT_ID: - required: false - PARTNER_CENTER_SELLER_ID: - required: false - PARTNER_CENTER_CLIENT_ID: - required: false - PARTNER_CENTER_CLIENT_SECRET: - required: false + release: + types: [published] workflow_dispatch: inputs: - run_id: - description: 'The id of the release run whose rssquick-microsoft-store bundle to submit' - type: string - required: true - version: - description: 'The version in that bundle, such as 1.4.1 (its CHANGELOG section becomes "What''s new")' + tag: + description: 'The release tag to submit, such as v1.4.1' type: string required: true permissions: contents: read - actions: read # download the bundle from another run + actions: read # find the release run, and download its bundle # One submission at a time: a second would replace the first's draft. concurrency: @@ -58,85 +48,138 @@ jobs: runs-on: windows-latest env: PRODUCT_ID: 9NFGQ354JDG5 + TAG: ${{ github.event.release.tag_name || inputs.tag }} TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }} SELLER_ID: ${{ secrets.PARTNER_CENTER_SELLER_ID }} CLIENT_ID: ${{ secrets.PARTNER_CENTER_CLIENT_ID }} CLIENT_SECRET: ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }} + GH_TOKEN: ${{ github.token }} steps: - - name: Check the Partner Center credentials - id: creds + - name: Check the tag and the Partner Center credentials + id: check shell: pwsh run: | + if ($env:TAG -notmatch '^v(\d+\.\d+\.\d+)$') { throw "'$env:TAG' is not a release tag such as v1.4.1." } + "version=$($Matches[1])" >> $env:GITHUB_OUTPUT $missing = @('TENANT_ID', 'SELLER_ID', 'CLIENT_ID', 'CLIENT_SECRET') | Where-Object { -not [Environment]::GetEnvironmentVariable($_) } if ($missing) { - Write-Host "::notice::Not submitted to the Microsoft Store: the PARTNER_CENTER_* secrets are not all set ($($missing -join ', ')). Upload the rssquick-microsoft-store bundle by hand, or see docs/STORE-PLAN.md." + Write-Host "::notice::Not submitted to the Microsoft Store: the PARTNER_CENTER_* secrets are not all set ($($missing -join ', ')). Upload the release run's rssquick-microsoft-store bundle by hand, or see docs/STORE-PLAN.md." "ready=false" >> $env:GITHUB_OUTPUT } else { "ready=true" >> $env:GITHUB_OUTPUT } - uses: actions/checkout@v7 - if: steps.creds.outputs.ready == 'true' + if: steps.check.outputs.ready == 'true' + with: + ref: ${{ env.TAG }} + + # Everything that can be checked before anything reaches Partner Center: that the tag ships + # Windows, which run built it, and the "What's new" text. A failure here leaves no half-made + # draft behind. + - name: Prepare + id: prep + if: steps.check.outputs.ready == 'true' + shell: pwsh + env: + VERSION: ${{ steps.check.outputs.version }} + run: | + # The same rule as build/release-platforms.sh: no file, or an empty one, means all three. + $listed = if (Test-Path RELEASE-PLATFORMS) { @((Get-Content RELEASE-PLATFORMS -Raw) -split '\s+' | Where-Object { $_ }) } else { @() } + if ($listed.Count -gt 0 -and $listed -notcontains 'windows') { + Write-Host "::notice::$env:TAG does not ship on Windows (RELEASE-PLATFORMS), so there is nothing for the Store." + "go=false" >> $env:GITHUB_OUTPUT + exit 0 + } + + $run = gh run list --repo $env:GITHUB_REPOSITORY --workflow release.yml --branch $env:TAG --status success --limit 1 --json databaseId --jq '.[0].databaseId' + if (-not $run) { throw "No successful release.yml run built $env:TAG." } + "run=$run" >> $env:GITHUB_OUTPUT + + # The version's CHANGELOG section from its first ### heading on: the paragraph above that + # says which platforms the release is for, which means nothing in the Store. Plain text, + # with markdown marks and links removed and wrapped lines joined, cut at the last whole + # line that fits the Store's limit. + $lines = Get-Content CHANGELOG.md + $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l.StartsWith("## [$env:VERSION]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no [$env:VERSION] section." } + $section = @(); $seenHeading = $false + foreach ($l in $lines[($start + 1)..($lines.Count - 1)]) { + if ($l.StartsWith('## [')) { break } + if ($l.StartsWith('### ')) { $seenHeading = $true } + if ($seenHeading) { $section += $l } + } + $text = ($section -join "`n") -replace '(?m)^#+ ', '' -replace '\*\*', '' -replace '`', '' -replace '\[([^\]]+)\]\([^)]+\)', '$1' -replace '\n +', ' ' + $notes = '' + foreach ($line in ($text.Trim() -split "`n")) { + $next = if ($notes) { "$notes`n$line" } else { $line } + if ($next.Length -gt 1500) { break } + $notes = $next + } + if (-not $notes) { throw "CHANGELOG.md's [$env:VERSION] section has nothing under a ### heading." } + Set-Content -Path "$env:RUNNER_TEMP\whats-new.txt" -Value $notes -Encoding utf8 + Write-Host $notes + "go=true" >> $env:GITHUB_OUTPUT - name: Download the Store bundle - if: steps.creds.outputs.ready == 'true' + if: steps.prep.outputs.go == 'true' uses: actions/download-artifact@v7 with: name: rssquick-microsoft-store - run-id: ${{ inputs.run_id }} + run-id: ${{ steps.prep.outputs.run }} github-token: ${{ github.token }} path: store + # Pinned: before 0.4.1, msstore wrapped its JSON output at 80 columns, which broke reading it. - name: Set up the Microsoft Store Developer CLI - if: steps.creds.outputs.ready == 'true' - uses: microsoft/microsoft-store-apppublisher@v1.1 + if: steps.prep.outputs.go == 'true' + uses: microsoft/microsoft-store-apppublisher@v1.4 + with: + version: v0.4.3 - name: Sign in to Partner Center - if: steps.creds.outputs.ready == 'true' + if: steps.prep.outputs.go == 'true' shell: pwsh - run: msstore reconfigure --tenantId $env:TENANT_ID --sellerId $env:SELLER_ID --clientId $env:CLIENT_ID --clientSecret $env:CLIENT_SECRET + run: | + msstore reconfigure --tenantId $env:TENANT_ID --sellerId $env:SELLER_ID --clientId $env:CLIENT_ID --clientSecret $env:CLIENT_SECRET + if ($LASTEXITCODE) { throw "Signing in to Partner Center failed ($LASTEXITCODE). An expired key is the usual cause: docs/STORE-PLAN.md." } - # Upload into a draft first, so "What's new in this version" can be set before it goes to - # certification. publish recreates the draft from the last published submission, so it has - # to come before the metadata edit, not after. + # Into a draft first, so "What's new" can be set before it goes to certification. publish + # recreates the draft from the last published submission, so it comes before the edit. - name: Upload the bundle - if: steps.creds.outputs.ready == 'true' + if: steps.prep.outputs.go == 'true' shell: pwsh + env: + VERSION: ${{ steps.check.outputs.version }} run: | $bundle = Get-ChildItem store -Filter *.msixbundle | Select-Object -First 1 if (-not $bundle) { throw 'The rssquick-microsoft-store artifact holds no .msixbundle.' } - if ($bundle.Name -notlike "*-${{ inputs.version }}.msixbundle") { - throw "The bundle is $($bundle.Name), not version ${{ inputs.version }}." - } - msstore publish store -i $bundle.FullName -id $env:PRODUCT_ID --noCommit - if ($LASTEXITCODE) { throw "msstore publish failed ($LASTEXITCODE)." } + if ($bundle.Name -ne "RSSQuick-$env:VERSION.msixbundle") { throw "The bundle is $($bundle.Name), not RSSQuick-$env:VERSION.msixbundle." } + msstore publish $bundle.FullName -id $env:PRODUCT_ID --noCommit + if ($LASTEXITCODE) { throw "msstore publish failed ($LASTEXITCODE). If a submission is already in certification, wait for it or cancel it in Partner Center." } - # The version's CHANGELOG section, as plain text, minus its heading. The Store shows it under - # "What's new in this version", for every language the listing has. + # The listing is English only. If another language is added, it needs its own text. - name: Set "What's new in this version" - if: steps.creds.outputs.ready == 'true' + if: steps.prep.outputs.go == 'true' shell: pwsh run: | - $version = '${{ inputs.version }}' - $lines = Get-Content CHANGELOG.md - $start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no [$version] section." } - $section = @() - foreach ($l in $lines[($start + 1)..($lines.Count - 1)]) { if ($l.StartsWith('## [')) { break }; $section += $l } - # "1.4.1 is a Windows-only release. The Mac..." means nothing in the Store, which only has Windows. - $section = $section | Where-Object { $_ -notmatch '-only release|is a release for .* only|have not changed,|has not changed,|and stay at \d' } - $notes = (($section -join "`n") -replace '(?m)^#+ ', '' -replace '\*\*', '' -replace '`', '' -replace '\n +', ' ').Trim() - if ($notes.Length -gt 1500) { $notes = $notes.Substring(0, 1497) + '...' } - - $submission = msstore submission get $env:PRODUCT_ID | Out-String | ConvertFrom-Json - foreach ($listing in $submission.Listings.PSObject.Properties) { $listing.Value.BaseListing.ReleaseNotes = $notes } - msstore submission update $env:PRODUCT_ID ($submission | ConvertTo-Json -Depth 100 -Compress) + $json = msstore submission get $env:PRODUCT_ID | Out-String + if ($LASTEXITCODE) { throw "msstore submission get failed ($LASTEXITCODE)." } + $submission = $json | ConvertFrom-Json + $notes = Get-Content "$env:RUNNER_TEMP\whats-new.txt" -Raw + $listings = @($submission.Listings.PSObject.Properties) + if (-not $listings) { throw 'The submission has no listings to put "What''s new" in.' } + foreach ($listing in $listings) { $listing.Value.BaseListing.ReleaseNotes = $notes.Trim() } + # Through a file: a whole submission can be longer than a Windows command line allows. + $payload = "$env:RUNNER_TEMP\submission.json" + $submission | ConvertTo-Json -Depth 100 | Set-Content -Path $payload -Encoding utf8 + msstore submission update $env:PRODUCT_ID --payload $payload if ($LASTEXITCODE) { throw "msstore submission update failed ($LASTEXITCODE)." } - name: Commit the submission for certification - if: steps.creds.outputs.ready == 'true' + if: steps.prep.outputs.go == 'true' shell: pwsh run: | msstore submission publish $env:PRODUCT_ID if ($LASTEXITCODE) { throw "msstore submission publish failed ($LASTEXITCODE)." } - msstore submission status $env:PRODUCT_ID + Write-Host "::notice::$env:TAG is in Microsoft Store certification. Partner Center emails when it passes, and the Store copy goes live then." diff --git a/CLAUDE.md b/CLAUDE.md index 48bcab7..daf8030 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Velopack replaces the whole program folder on update, so the `RSS.opml` beside t ### The Microsoft Store package -`docs/STORE-PLAN.md` is the plan and its status. `build/package-store.ps1` (`package.cmd store`) builds an unsigned MSIX per architecture, bundled as `artifacts/RSSQuick-.msixbundle`, from `build/store/AppxManifest.xml` and the Partner Center identity in `build/store/identity.json`; the Store signs it. It is the same program published as loose files rather than single-file, so the Store's block-level updates can diff it. `makeappx` comes from the `Microsoft.Windows.SDK.BuildTools` NuGet package, and the logos from the iOS app icon. `-Install` registers the unpacked layout on this machine (Developer Mode) to try it. +`docs/STORE-PLAN.md` is the plan and its status. `build/package-store.ps1` (`package.cmd store`) builds an unsigned MSIX per architecture, bundled as `artifacts/RSSQuick-.msixbundle`, from `build/store/AppxManifest.xml` and the Partner Center identity in `build/store/identity.json`; the Store signs it. It is the same program published as loose files rather than single-file, so the Store's block-level updates can diff it. Publishing a GitHub release that ships on Windows runs `.github/workflows/store-submit.yml`, which submits that release's bundle for certification through the Store submission API (`msstore`, pinned) once the `PARTNER_CENTER_*` secrets exist; STORE-PLAN.md has the setup. `makeappx` comes from the `Microsoft.Windows.SDK.BuildTools` NuGet package, and the logos from the iOS app icon. `-Install` registers the unpacked layout on this machine (Developer Mode) to try it. - **A Store copy never looks for updates.** `PackageIdentity.IsPackaged` is checked in App, which then sets `UpdatedByStore` instead of starting the check; Check for Updates and About say the Store keeps it current. Without the check, a Store copy is "not installed by Velopack" and falls into the portable path, offering a GitHub download. - **Everything saved goes in `%APPDATA%\RSSQuick`, as files, and nothing is written beside the program.** A package's install folder is read-only, and Windows redirects new files written under `%APPDATA%` into the package's private folder while still letting it read files already there. Keeping everything in one folder keeps that behaviour the same for all of it; data scattered elsewhere, or in the registry, is where a packaged copy goes wrong. diff --git a/WORKFLOW.md b/WORKFLOW.md index 7f13d60..c633ed4 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -29,7 +29,7 @@ Then: Pushing the tag runs three workflows, each for the platforms `RELEASE-PLATFORMS` lists; a workflow for a platform it leaves out stops at its first job and builds nothing. `.github/workflows/release.yml` checks the tag against `VERSION`, runs the tests, rebuilds all four Windows artefacts on a clean runner, and opens a **draft** GitHub release with them attached. `.github/workflows/macos-release.yml` does the same for the disk image on a macOS runner and attaches it to that same draft. `.github/workflows/ios-release.yml` builds the iPhone and iPad version at the same version, uploads it to TestFlight, and submits it to the External TestFlight group (the external testers, with the public link) for Beta App Review; unlike the draft, that goes to testers as soon as Apple approves it. See ios/README.md. -After the Windows job, `release.yml` submits the Microsoft Store bundle to Partner Center for certification through `store-submit.yml`, once its credentials exist (docs/STORE-PLAN.md, *Submitting through the API*); certification takes hours to days, so the Store copy trails the GitHub release. +Publishing a release that ships on Windows also submits its Microsoft Store bundle to Partner Center for certification, through `store-submit.yml`, once its credentials exist (docs/STORE-PLAN.md, *Submitting through the API*). Certification takes hours to days, so the Store copy trails the GitHub release. `release.yml`'s first job creates the draft on every tag, whichever platforms it is for, and opens its notes with a line such as **"RSS Quick 1.4.1 is a Windows-only release. The Mac, iPhone and iPad versions have not changed, so there is nothing new to install there."** (`build/release-platforms.sh` writes it; a release on all three has no such line). The platform jobs then attach their files to it. The macOS job spends most of its time waiting on Apple's notary service, so by the time it uploads, the draft exists. The draft is made only once: re-running the job, or re-tagging, leaves notes you have edited alone. A fix for iPhone and iPad alone makes no GitHub release, because there would be nothing on it to download; the changelog records it. diff --git a/docs/STORE-PLAN.md b/docs/STORE-PLAN.md index 46443ed..dae71e8 100644 --- a/docs/STORE-PLAN.md +++ b/docs/STORE-PLAN.md @@ -5,7 +5,8 @@ https://apps.microsoft.com/detail/9nfgq354jdg5?hl=en-US&gl=US. It is the same 1.4.0 released on GitHub and TestFlight on 2026-10-01, packaged from the `v1.4.0` tag. From the next version on, a `v*` tag's release workflow builds the Store bundle too, and `store-submit.yml` submits it through the Store -submission API once the Partner Center credentials are in place (below); until then, by hand. +submission API when the GitHub release is published, once the Partner Center credentials are in +place (below); until then, by hand. The Partner Center product is **RSSQuick**, an MSIX or PWA app, Store ID **9NFGQ354JDG5**, and its identity is in `build/store/identity.json`. @@ -154,13 +155,20 @@ it can only update a product that has been published once. ### Submitting through the API `.github/workflows/store-submit.yml` uses the Microsoft Store Developer CLI (`msstore`), which -calls the Store submission API. `release.yml` runs it after the Windows job on every tag that ships -Windows: it downloads that run's bundle, uploads it into a draft submission, sets "What's new in -this version" from the version's CHANGELOG section (without the line about other platforms, which -means nothing in the Store), and commits it for certification. Run it by hand from the Actions tab -with a release run's id and version to submit a bundle built earlier. Without the credentials it +calls the Store submission API. It runs when a GitHub release is published, not when the tag is +pushed: the draft is there so the build is checked with a screen reader first, and the Store waits +for the same check. It skips a release that does not ship on Windows, finds the `release.yml` run +that built the tag, uploads that run's bundle into a draft submission, sets "What's new in this +version" from the version's CHANGELOG section (from its first `###` heading, so not the line about +other platforms), and commits it for certification. The Store copy goes live when certification +passes. Run it by hand from the Actions tab with a tag to submit again. Without the credentials it skips with a notice, and the bundle is still an artifact of the release run to upload by hand. +A submission still in certification blocks the next one: `msstore` replaces a pending submission +by deleting it, which Partner Center refuses during certification. Wait for it, or cancel it in +Partner Center, then run the workflow by hand. `msstore` is pinned to v0.4.3: earlier versions +wrapped their JSON output and broke reading it. + The credentials, made once in Partner Center: 1. **Account settings → Tenants**: the Partner Center account must have a Microsoft Entra tenant.