Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,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
Expand Down
185 changes: 185 additions & 0 deletions .github/workflows/store-submit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
name: Microsoft Store Submission

# 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.
#
# 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.

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'The release tag to submit, such as v1.4.1'
type: string
required: true

permissions:
contents: read
actions: read # find the release run, and download its bundle

# 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
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 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 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.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.prep.outputs.go == 'true'
uses: actions/download-artifact@v7
with:
name: rssquick-microsoft-store
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.prep.outputs.go == 'true'
uses: microsoft/microsoft-store-apppublisher@v1.4
with:
version: v0.4.3

- name: Sign in to Partner Center
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
if ($LASTEXITCODE) { throw "Signing in to Partner Center failed ($LASTEXITCODE). An expired key is the usual cause: docs/STORE-PLAN.md." }

# 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.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 -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 listing is English only. If another language is added, it needs its own text.
- name: Set "What's new in this version"
if: steps.prep.outputs.go == 'true'
shell: pwsh
run: |
$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.prep.outputs.go == 'true'
shell: pwsh
run: |
msstore submission publish $env:PRODUCT_ID
if ($LASTEXITCODE) { throw "msstore submission publish failed ($LASTEXITCODE)." }
Write-Host "::notice::$env:TAG is in Microsoft Store certification. Partner Center emails when it passes, and the Store copy goes live then."
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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-<version>.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.
Expand Down
2 changes: 2 additions & 0 deletions WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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.

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.
Expand Down
42 changes: 39 additions & 3 deletions docs/STORE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
**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 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`.

Expand Down Expand Up @@ -147,8 +149,42 @@ 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. 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.
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

Expand Down
Loading