Windows 应用自动上架 Microsoft Store 的自动化实践 如果你有 Electron 应用想要上架 Microsoft Store大概会碰到这样的麻烦Store 不支持分离的安装流程——你得把桌面应用和服务器负载打包成一个完整的 AppX/MSIX 包。这倒也罢了。问题还在后面呢。每次发新版本你得检查桌面和服务器版本是否更新从对应的 tag 检出代码下载并注入服务器负载构建 MSIX 包手动上传到 Microsoft Store配置商店信息和定价如果每一步都要手动操作那也太折腾人了。而且容易出错哪一步做了哪一步没做自己都未必记得清楚。其实这也不能怪谁毕竟手动操作本就容易遗漏。只是我们实在不想每次都这样折腾于是决定彻底解决这个问题——让整个流程自动跑起来。关于 HagiCode本文分享的方案来自我们在 HagiCode 项目中的实践经验。作为一个 AI 代码助手HagiCode 提供桌面端和 Web 端需要支持多种分发渠道。在实现 Windows Store 自动上架的过程中我们总结出了一套完整的自动化方案。说起来这大概也算是个意外收获。本来只是为了省点时间没想到最后做出来这么一套东西。技术架构分析这个问题其实涉及多个技术层次的协调。我们可以把它分成五层版本协调层首先需要知道什么时候需要发布新版本。我们需要从 Azure 索引我们用来存储构建产物的 blob 存储中解析出桌面和服务器组件的最新版本然后判断是否需要生成新的 Store 包。这就像是在问自己现在该做这件事了吗工作空间管理层AppX 的构建依赖于源代码级别的配置和运行时布局所以不能简单地用现成的构建产物重新打包。我们需要从桌面仓库的特定 tag 检出代码确保构建使用的源代码状态是正确的。毕竟源代码不对后面再怎么努力也是白搭。运行时打包层这是核心部分。我们需要把服务器负载注入到桌面应用的打包布局中。这样当应用启动时会检测到打包的运行时并进入 Steam 模式离线模式。这一步做不好前面所有的努力也都白费了。构建输出层使用 electron-builder 生成符合 Store 要求的 MSIX 包。这一步需要在 Windows 环境中运行因为 AppX 构建需要 Windows SDK。有些事情就是要在对的地方做换地方就不行。发布分发层最后是发布到 GitHub Releases 和 Microsoft Store。GitHub Release 作为备份和版本追踪Microsoft Store 则面向终端用户。一切准备就绪就差最后这一哆嗦了。整体架构设计┌─────────────────────────────────────────────────────────────┐│ package-release workflow │├─────────────────────────────────────────────────────────────┤│ ┌─────────────────┐ ┌─────────────────┐ ││ │ resolve_plan │───▶│ build │ ││ │ (版本解析与跳过) │ │ (MSIX 构建) │ ││ └─────────────────┘ └────────┬────────┘ ││ │ │ ││ ▼ ▼ ││ ┌─────────────────┐ ┌─────────────────┐ ││ │ skip_summary │ │ publish │ ││ │ (跳过报告) │ │ (发布) │ ││ └─────────────────┘ └────────┬────────┘ ││ │ ││ ▼ ││ ┌─────────────────┐ ││ │ publish_store │ ││ │ (Store 发布) │ ││ └─────────────────┘ │└─────────────────────────────────────────────────────────────┘整个流程由 GitHub Actions 驱动可以定时触发每 4 小时或手动触发。手动触发时可以指定特定的桌面版本和服务器版本或者强制重建。其实四小时一次也不算频繁毕竟代码更新总是有快有慢只是这样比较保险罢了。实现详解1. 构建计划解析第一步是确定是否需要构建新版本。我们需要解析桌面和服务器组件的当前版本然后检查是否已经存在对应的发布。// scripts/resolve-dispatch-build-plan.mjsexport async function resolveDispatchBuildPlan({eventName,eventPayload,desktopAzureSasUrl,serverAzureSasUrl,}) {// 解析触发器输入const trigger normalizeTriggerInputs({ eventName, eventPayload });// 从 Azure 索引解析桌面和服务器版本const [desktopRelease, serverRelease] await Promise.all([resolveIndexRelease({azureSasUrl: desktopAzureSasUrl,platformId: win-x64}),resolveIndexRelease({azureSasUrl: serverAzureSasUrl,platformId: win-x64})]);// 生成发布 tagconst desktopTag normalizeGitTag(desktopRelease.version);const releaseTag deriveStoreReleaseTag(desktopRelease.version, serverRelease.version);// 比如desktop-v1.2.3-server-v4.5.6// 检查是否已存在避免重复构建const existingRelease await findReleaseByTag(packerRepository, releaseTag);const shouldBuild !existingRelease || trigger.forceRebuild;return {release: { tag: releaseTag, exists: Boolean(existingRelease) },build: { shouldBuild, skipReason },upstream: { desktop: { tag: desktopTag }, server }};}这个步骤的关键在于跳过逻辑——如果相同版本的组合已经构建过就没有必要再跑一遍完整的构建流程。这样可以节省 CI 成本和时间。重复做同样的事情大概也没什么意义。毕竟时间和资源都是有限的。2. 工作空间准备确定要构建后需要准备一个干净的构建环境。我们使用 git worktree 来从特定的 tag 检出代码。// scripts/prepare-packaging-workspace.mjsexport async function preparePackagingWorkspace({planPath,platformId,workspacePath,desktopSourcePath}) {// 使用 git worktree 从特定 tag 检出桌面代码await runCommand(git, [-C, resolvedDesktopSourcePath,worktree, add, --detach,desktopWorkspace,refs/tags/${plan.upstream.desktop.tag}]);// 验证工作空间const validation await validateDesktopWorkspace({desktopWorkspace,storePackageConfig});// 创建工作空间清单const workspaceManifest {desktopWorkspace,runtimeInjectionRoot: validation.runtimeRoot,desktopTag: plan.upstream.desktop.tag,desktopRef,};return workspaceManifest;}使用 worktree 的好处是不影响主工作目录构建可以并行进行而且构建完成后可以轻松清理。毕竟谁也不希望因为构建把主工作目录搞得乱七八糟。3. 服务器负载注入这一步是把服务器负载下载并注入到正确的位置。// scripts/stage-server-payload.mjsexport async function stageServerPayload({planPath,workspacePath,platformId,azureSasUrl}) {// 从 Azure 索引下载服务器负载const assetSource resolveAssetDownloadUrl({ asset, sasUrl: azureSasUrl });await downloadFromSource({sourceUrl: assetSource,destinationPath: downloadPath});// 解压并验证await extractArchive(downloadPath, extractionPath);const runtimeRoot await resolveRuntimeRoot(extractionPath);const validation await validateServerPayloadRoot(runtimeRoot, platformId);// 注入到打包运行时布局// 目标路径是 resources/portable-fixed/current// 这个路径会被映射到 AppX 内的 extra/portable-fixed/currentawait copyDir(runtimeRoot, targetPath);}关键是路径映射——resources/portable-fixed/current会被打包到 AppX 的extra/portable-fixed/current这样应用启动时就会检测到本地运行时并进入离线模式。路径这东西错一点都不行。一点偏差可能就找不到了。4. MSIX 构建有了准备好的工作空间和服务器负载就可以构建 MSIX 包了。// scripts/build-appx.mjsexport async function buildAppx({planPath,workspacePath,platformId}) {// 生成 Store 特定的 electron-builder 配置覆盖const overlayConfig await writeStoreElectronBuilderConfig({desktopWorkspace: workspaceManifest.desktopWorkspace,sourceConfigPath: storePackageConfig.desktop.electronBuilderConfigPath,outputConfigPath: electron-builder.store.yml});// 运行桌面构建命令await runShellCommand(buildDesktopStoreCommand(overlayConfig.outputPath, desktopScripts),workspaceManifest.desktopWorkspace);// 收集 MSIX 输出const storeOutputs await findStoreOutputs(pkgDirectory);const artifactPath path.join(workspaceManifest.outputDirectory,artifactFileName);await copySingleFile(primaryOutput, artifactPath);}electron-builder 配置需要包含 Store 特定的元数据比如包身份、发布者显示名称等。这些信息会在 Store 提交时使用。配置不对包也打不出来。这也没什么好说的。5. 发布到 GitHub构建完成后需要把产物发布到 GitHub Releases。// scripts/publish-release.mjsexport async function publishRelease({planPath,artifactsDir,outputDir,forceDryRun}) {// 构建发布产物清单const publicationArtifacts await buildPublicationArtifacts({plan,artifactsDir,outputDir});if (!dryRun) {// 创建或更新 GitHub Releaseconst releaseResult await upsertReleaseNotes(plan.release.repository,plan.release.tag,token,{ name, body });// 上传资产for (const upload of publicationArtifacts.uploads) {await uploadReleaseAsset({release: releaseResult.release,filePath: upload.filePath,fileName: upload.fileName});}}}