最近给 Flutter 应用接入了 Microsoft Store 的自动化提交流程。本以为账号配置会是个一次性的体力活——注册、拿凭证、写进 CI——结果合作伙伴中心(Partner Center)和 Entra ID 这一侧本身就踩了几个坑,落地之后 CI 实跑又冒出来几个更隐蔽的问题。因此在这里记录一下。

1. 背景

Table Habit 的发布自动化已经通过 fastlane 覆盖了 Android(Google Play)和 iOS/macOS(App Store)——Android 侧配置见 android/fastlane/Fastfile,iOS/macOS 同理。接下来推进 Windows / Microsoft Store。

最初的设想是:Windows 这边也应该能找到类似 fastlane 的现成方案直接套上去。结果翻了一圈官方文档和社区项目,并没有找到一个等价物——最后能用的就是 Microsoft 官方的 msstore CLI,剩下的部分(账号配置、CI 拼装)都得自己手动走一遍。

落地 CI 之前需要先确认两件事:

  1. 应用必须已在 Partner Center 中创建,拿到应用 ID(Product ID)并在 GitHub 中配置 ——这个值跟 iOS/macOS 的 Bundle ID 一样是公开标识。
  2. 代表 CI 的 Microsoft Entra ID 应用注册(App Registration)必须在 合作伙伴中心(Partner Center) 分配「经理 (Windows)」角色。

下面先走一遍 Partner Center + Entra ID + msstore CLI 的手动配置流程,再落地代码。最终 CI 落地的成品见:

2. 合作伙伴中心(Partner Center)账号侧配置

2.1. 关联 Microsoft Entra ID 租户(Tenant)

可能很多独立开发者跟我一样以为关联 Entra ID 租户(Tenant,以前叫 Azure AD)需要公司账号——其实 个人开发者账号也能关联 Entra ID 租户,官方文档明确支持个人账号事后关联 Entra ID 做多用户管理。具体操作步骤:

  1. 用注册合作伙伴中心(Partner Center)开发者账号的同一个 Microsoft 账号登录 entra.microsoft.com——账号一致,后面关联起来省事。
  2. 左侧导航找「标识」→「概述」,或者直接搜”创建租户”,进入创建向导后租户类型选 Microsoft Entra ID(不是面向终端用户的 Entra External ID/B2C,那个是给应用做用户认证用的,跟这里的 CI 场景没关系)。
  3. 填写组织名称、初始域名(格式固定是 <自定义前缀>.onmicrosoft.com,全局唯一,先到先得)和国家/地区,提交创建。

    第一次提交的时候撞上了 Error Code: 715-123280,换浏览器、清缓存都没用,一度以为是 provisioning 流程被自动校验挡住的已知问题。后来发现根本原因是当前登录的 Microsoft 账号邮箱本身就没法用来创建 Entra ID 租户——换一个邮箱重新登录、重新提交就解决了。如果你也撞上这个错误码,先试试换邮箱。

  4. 创建过程中会要求绑定一张信用卡——按 Microsoft Entra ID Free 官方说明,这张卡只用于身份验证,是 Entra ID Free 的强制步骤,照提示填实际卡信息即可。
  5. 流程一般几分钟就能跑完,完成后页面会自动切到新租户。记下这个租户的域名和 Tenant ID。
  6. 回到合作伙伴中心,点右上角齿轮图标 → 「帐户设置」,左侧导航「组织配置文件」分组下选 Tenants(这一项没有被本地化,显示的就是英文原文)→「将 Microsoft Entra ID 与合作伙伴中心账号关联」,用第 5 步那个租户里的账号登录确认域名信息,点「确认」完成关联(详见官方文档 Associate an existing Microsoft Entra ID tenant)。关联完成后,合作伙伴中心才能在 2.3 里识别到这个租户下创建的 App Registration。

详见 Microsoft Entra ID 文档

entra.microsoft.com 新建 tenant 流程

2.2. 创建 CI 用的应用注册(App Registration)

在 Entra ID 租户里创建一个应用注册(App Registration)代表 CI,具体操作步骤(详见 应用注册快速入门):

  1. 登录 entra.microsoft.com,确认当前租户已经切到 2.1 里新建的那个(右上角齿轮图标可以切换租户)。
  2. 左侧导航找到「应用注册」,点击「新注册」。

    Entra「应用注册」列表页,左侧导航和「新注册」按钮位置

  3. 填写一个能区分用途的名称(比如 mhabit-windows-store-ci);「受支持的帐户类型」选 仅单个租户——这个 app 只代表自己的 CI,不需要兼容多租户或个人账号登录。
  4. 「重定向 URI」整段留空——CI 走的是客户端凭据流程(client credentials flow),靠 Client ID + Secret 直接换 token,没有用户登录跳转这一步,不需要配回调地址。

    「注册应用程序」表单:受支持的帐户类型选「仅单个租户」,重定向 URI 留空

  5. 点击「注册」,进入应用的概述页,记下 目录(租户) ID应用程序(客户端) ID——这两个就是后面要用的 Tenant ID 和 Client ID。

    应用概述页:应用程序(客户端) ID 与目录(租户) ID 的位置

  6. 左侧导航切到「证书和密码」→「客户端密码」→「新客户端密码」,填写「说明」和「截止期限」(官方推荐 180 天/6 个月),点击「添加」。

    「证书和密码」页:新建客户端密码的导航路径与表单

  7. 添加完成后客户端密码列表会多出一行,「值」这一列就是 Client Secret,只在这一次完整显示——当场复制保存,刷新或离开页面之后就只剩打码后的样子。注意旁边的「机密 ID」不是密码本身,只是这条密码记录的标识符,别认错。

    客户端密码列表:「值」与「机密 ID」是两个不同的字段

2.3. 给 App 分配「经理 (Windows)」角色

回到 Partner Center,路径是 Account settings → User management → Microsoft Entra applications → Add Microsoft Entra application,把上一步创建的 app 加进来并分配角色(详见 Partner Center 文档)。

角色分配是个多选框——经理 (Windows)、开发人员、业务参与者、财务参与者、营销人员——很容易纠结要不要多选几个才保险。实际上官方文档只要求 经理 (Windows) 一个角色,它的权限范围本身已经覆盖”开发人员”能做的操作。

2.4. 获取卖家 ID(MSSTORE_SELLER_ID

这是我踩的最迷惑的一个坑。Partner Center 的”标识符”(Identifiers)页面会列出 Windows 发布者 IDCN=... 格式,是包签名身份的 Publisher CN)、Windows Phone 发布者 IDSymantec ID——这里面 没有一个是 Seller ID。

真正的 Seller ID 藏在 帐户设置 (Account settings) → Legal info,是一个纯数字——这里有个小陷阱:左侧导航栏里这一项压根没有被本地化,显示的就是英文原文 “Legal info”,只有进去之后页面标题才会同时显示”帐户设置 法律信息”。对照一下:
名称 位置 格式 是不是 Seller ID
Windows 发布者 ID 标识符页面 CN=...
Windows Phone 发布者 ID 标识符页面 -
Symantec ID 标识符页面 -
Seller ID Legal info 页面 纯数字

这个命名上的混淆连官方 msstore-cli 的 issue 里都有人在问,文档里笼统说的 “Publisher ID / Seller ID” 和 Partner Center 页面上的实际字段名对不上,第一次确实很难找。

![帐户设置 Legal info 页面:卖家 ID 与 Windows 发布者 ID、Windows Phone 发布者 ID 同列在「帐户详细信息」里](https://cdn.jsdelivr.net/gh/FriesI23/blog-image@master/img/flutter-windows-store-submission-automation-07.png){: width=”800” }

3. 本机 msstore CLI 配置

至此账号侧需要的 4 个值都齐了(租户 ID / Tenant ID、应用程序(客户端) ID / Client ID、客户端密码 / Client Secret、卖家 ID / Seller ID),接下来在本机装 msstore CLI 完成认证。

我的环境是 macOS,所以需要先有 .NET 9 Runtime,然后:

brew install microsoft/msstore-cli/msstore-cli

安装完成后跑 reconfigure 把凭证写进本机配置:

msstore reconfigure \
  --tenantId <TENANT_ID> \
  --sellerId <SELLER_ID> \
  --clientId <CLIENT_ID> \
  --clientSecret <CLIENT_SECRET>
  • TENANT_ID2.2 第 5 步的「目录(租户) ID」
  • SELLER_ID2.4 节的 Seller ID
  • CLIENT_ID2.2 第 5 步的「应用程序(客户端) ID」
  • CLIENT_SECRET2.2 第 7 步的客户端密码「值」

认证完成后用 apps list 获取 Product ID,需要跟仓库 README 里已经公开的 Microsoft Store 徽章链接(apps.microsoft.com/detail/<PRODUCT_ID>)做一次比对,确认没取错应用:

msstore apps list

...
✅ Retrieved Managed Applications
┌───┬──────────────┬──────────────┬─────────────────────┐
│   │ ProductId    │ Display Name │ PackageId           │
├───┼──────────────┼──────────────┼─────────────────────┤
│ 1 │ 9N********GZ │ Table Habit  │ Friesi23.TableHabit │
└───┴──────────────┴──────────────┴─────────────────────┘

4. 写进 GitHub Environment

至此拿到了 5 个值,但 Secrets 和普通变量需要分别处理:

性质 写入方式
MSSTORE_TENANT_ID / MSSTORE_CLIENT_ID / MSSTORE_CLIENT_SECRET / MSSTORE_SELLER_ID 凭证 gh secret set <NAME> --env store-submission --repo <owner>/<repo>,终端交互输入,不经过对话历史
MSSTORE_PRODUCT_ID 公开标识(跟 iOS/macOS 把 bundle id 直接写死一样) gh variable set MSSTORE_PRODUCT_ID --env store-submission --body "<productId>",直接写,不用交互

没什么好说的,4 个 Secret 自己在终端里敲,PRODUCT_ID 因为不是凭证直接用 gh variable set 写进去就行。 另外我将所有 KV 都写入了 store-submission 环境,具体放在 repo 全局还是环境都可以。

5. CI 落地

落地成 composite action + reusable workflow,方便跟仓库里现有的几个发布 job 拼装。

新增 composite action build_windows_store:复用 setup_flutter,构建命令直接调本机开发者也会跑的 scripts/build_msix_store.cmd,把 Store 身份参数(--identity-name/--publisher 等)集中写在脚本里,避免在 action 和脚本之间重复一份。--store 模式下 msix 不需要本地签名(Microsoft Store 提交时会自行重新签名),所以这个 action 没有签名相关的 input,跟 build_windows(GitHub Release 用,要 PFX 签名)完全独立。

新增 _submit-windows-store.ymlworkflow_call + dry_run 输入,environment: store-submission。用官方 microsoft/microsoft-store-apppublisher action 装 CLI、reconfigure 写认证,然后是后面”坑”里提到的草稿归属校验(check_draft_ownership.ps1)——校验通过才会触发耗时的 Windows 构建,没必要为一个不属于这条 CI 的草稿白跑一次构建。publish 永远带 --noCommit,是否真正执行 submission publish 完全交给 dry_run 控制。最后一步用后面”坑”里那个细粒度 PAT,把这次的 submission ID 写回 Environment Variable,留给下一次 CI 跑时做归属校验。

修改 release-app.yml:加一个跟 build-windows-msix 并行的 submit-windows-store job,只 needs: pre-build,不进 post-buildneeds 链。另外加了一条 if:pre-release 的 tag 直接跳过——Microsoft Store 没有公开的 beta 渠道(package flights/私有受众都是邀请制),pre-release 没有地方可提交。

修改 manual-submit.yml:加 windows 输入(skip/submit 二选一,没有 track 概念)和对应 job,直接调用,不需要 resolve-apple-store-track 那一套。

# _submit-windows-store.yml (part)
on:
  workflow_call:
    inputs:
      dry_run:
        type: boolean
        default: false

jobs:
  submit-windows-store:
    runs-on: windows-2022
    environment: store-submission
    steps:
      - uses: actions/checkout@v6
      - name: Setup Microsoft Store Developer CLI
        uses: microsoft/microsoft-store-apppublisher@v1.3
      - name: Configure Microsoft Store Developer CLI
        shell: pwsh
        env:
          MSSTORE_TENANT_ID: $
          MSSTORE_SELLER_ID: $
          MSSTORE_CLIENT_ID: $
          MSSTORE_CLIENT_SECRET: $
        run: >
          msstore reconfigure
          --tenantId $env:MSSTORE_TENANT_ID
          --sellerId $env:MSSTORE_SELLER_ID
          --clientId $env:MSSTORE_CLIENT_ID
          --clientSecret $env:MSSTORE_CLIENT_SECRET
      # 校验当前 Partner Center 上 pending 的草稿是不是上一次 CI 自己创建
      # 的,不是就跳过,避免覆盖人工在 Partner Center 准备中的草稿。
      - id: ownership
        name: Check pending submission ownership
        shell: pwsh
        run: >
          scripts\msstore\check_draft_ownership.ps1
          -ProductId "$"
          -KnownSubmissionId "$"
      - id: build
        if: $
        uses: ./.github/actions/build_windows_store
      - name: Stage package
        if: $
        shell: pwsh
        env:
          MSIX_PATH: $
        run: msstore publish $env:MSIX_PATH -id $ --noCommit
      - id: changelog
        name: Stage changelog metadata
        if: $
        shell: pwsh
        run: >
          scripts\msstore\update_changelog.ps1
          -ProductId "$"
      - name: Finalize submission
        if: $
        shell: pwsh
        run: msstore submission publish $
      - name: Record CI-owned submission Id
        if: $
        shell: pwsh
        env:
          GH_TOKEN: $
        run: >
          gh variable set MSSTORE_LAST_CI_SUBMISSION_ID
          --body "$"
          --env store-submission
          --repo $

代码写完、真实 CI 跑起来之后,又踩了几个一开始没预料到的坑。下面按时间顺序记录。

setup_flutter 在 cmd 步骤里找不到 flutter/dart

根因很简单但藏得很深:共享 action setup_flutter"<目录>:$PATH" 整行写进了 $GITHUB_PATH。bash 步骤”凑巧”能工作(bash 按 :PATH),但 Windows cmd 步骤只认 ; 作为路径分隔符,整行 : 直接被当成目录名的一部分,自然找不到 flutter/dart

之前四个 build_* action 全部用的 bash 步骤,这个 bug 从来没暴露过——Windows Store 这条是第一个用 cmd 步骤跑裸 flutter/dart 命令的消费方。修法也简单:

只写目录本身(去掉 :$PATH),对其余四个调用无影响。

msstore CLI 输出的 JSON 不能直接 ConvertFrom-Json

msstore submission get 会把 JSON 当人类可读的文本来打印——按终端列宽强制换行。如果输出里有长的中文字段(编码成 \uXXXX 转义),换行边界刚好切到转义序列中间,PowerShell 把多行拼回去再 ConvertFrom-Json 就会报 Invalid Unicode escape sequence

本机用同一个真实账号能稳定复现一模一样的截断位置,确认不是网络波动或 CI 特有。修法是截取第一个 { 到最后一个 } 之间,再去掉所有原始换行——JSON 在 token 之间本来就不在乎空白,真正的换行应该被编码成 \n 两个字符,不会是裸换行。

# 示意:截取 { 到 } 之间再去除裸换行
# $raw = msstore submission get ...
# $json = ($raw -join '') -replace '...'

这种设计缺陷在非 ASCII 内容上几乎是必现的。

官方文档摘要的字段名是错的

网上摘要写的是 listings[language].whatsNew,但真实账号读出来的是 Listings.<lang>.BaseListing.ReleaseNotes(legacy API 的 PascalCase 形状)。

草稿归属标记走了三个方案才落地

CI 需要一种方式来判断”当前线上是否已经有本次构建对应的草稿”,避免重复创建。这个标记存在哪里,前后试了三个方案:

方案 结果 放弃/采用原因
写进 FriendlyName 放弃 服务端只读/自动生成,写入被静默丢弃(靠真实调一次 updateMetadata 再读回来才发现,不是看文档看出来的)
写进 Description 放弃 技术上可写,但这是真实展示给 Store 用户的商品描述文案,提交链路没有人工检查点,一旦标记没清理干净就会被真实发布出去
store-submission Environment 下的固定 Variable 采用 不读写 Submission 任何字段;中途也试过 actions/cache,但 cache 7 天不用会被清理,且清理后的误判不会自动恢复,等于 CI 卡死等人工,因此最后使用 Variable 来存储数据

这里有个方法论上的教训:”字段技术上可写” ≠ “适合用来存自动化标记”——选状态存储位置时,要先确认谁会看到这个字段,而不是只确认能不能写进去。Description 就是典型的反例:能写,但用户可见,风险太高。

最终落地为两个 PowerShell 脚本:

两个脚本都可以脱离 CI 在本机直接跑,跟 fastlane lane 的用法一致。

细粒度 PAT 第一次配的权限类别选错了

gh variable set --env store-submission ... 需要 PAT 有写入权限。第一次配的时候以为勾上 “Variables: read and write” 就够了,结果真实跑直接报 403。查了文档才发现:Environment 级的 Variable 接口(带 --env 参数那种)实际归在 “Environments” 这个权限类别下,跟仓库/组织级 Variable 用的 “Variables” 类别是两个独立的勾选项——这个分类非常不直观,很容易选错而不自知。把 “Environments” 也勾上(保留原来的 “Variables”,两个都给 Read and write)之后才跑通。

细粒度 PAT 权限选择页面:Environments 与 Variables 是两个独立的勾选项,都需要 Read and write

总结

最终整体流程其实就是账号配置 → CLI 认证 → 写 Environment → CI 落地这四步,账号部分卡住的点基本都在第一步,代码部分的坑全在最后一步,中间两步反而是最顺利的。

参考资料

  1. msstore-cli—Microsoft Store CLI
  2. Quickstart: Register an application—Microsoft identity platform
  3. Microsoft Entra service principal—Partner Center
  4. Microsoft Entra ID Free—Microsoft Cost Management
  5. Associate an existing Microsoft Entra ID tenant with your Partner Center account