DEV Community

Hagicode
Hagicode

Posted on • Originally published at docs.hagicode.com

Automated Windows App Deployment to Microsoft Store

Automated Windows App Deployment to Microsoft Store

How to achieve end-to-end automation from version parsing to Store release, saying goodbye to tedious manual operations.

Background

If you have an Electron application you want to list on the Microsoft Store, you'll likely run into this problem: the Store doesn't support separate installation processes—you have to package the desktop application and server payload together into a complete AppX/MSIX package.

That would be manageable enough. But the problem continues. Each time you release a new version, you need to:

  1. Check if desktop and server versions have been updated
  2. Check out code from the corresponding tags
  3. Download and inject the server payload
  4. Build the MSIX package
  5. Manually upload to the Microsoft Store
  6. Configure store information and pricing

If every step requires manual operation, that's too much hassle. And it's error-prone—you might not even remember which steps you've completed and which you haven't.

Actually, this isn't really anyone's fault—manual operations are naturally prone to omissions. It's just that we really didn't want to go through this hassle every time, so we decided to solve this problem once and for all—by automating the entire process.

About HagiCode

The solution shared in this article comes from our practical experience in the HagiCode project. As an AI code assistant, HagiCode provides both desktop and web clients and needs to support multiple distribution channels. In implementing automated Windows Store deployment, we've developed a complete automation solution.

Speaking of which, this was probably an unexpected bonus. What started as a simple time-saver ended up becoming this comprehensive solution.

Technical Architecture Analysis

This problem actually involves coordinating multiple technical layers. We can break it down into five layers:

Version Coordination Layer

First, we need to know when a new version needs to be released. We need to parse the latest versions of desktop and server components from an Azure index (a blob storage we use to store build artifacts), then determine whether a new Store package needs to be generated.

It's like asking yourself: Is it time to do this now?

Workspace Management Layer

AppX builds depend on source code-level configuration and runtime layout, so we can't simply repackage existing build artifacts. We need to check out code from a specific tag in the desktop repository to ensure the correct source code state is used for the build.

After all, if the source code is wrong, everything else is wasted effort.

Runtime Packaging Layer

This is the core part. We need to inject the server payload into the desktop application's packaging layout. This way, when the application starts, it will detect the packaged runtime and enter Steam mode (offline mode).

If this step isn't done well, all previous efforts are wasted.

Build Output Layer

Use electron-builder to generate an MSIX package that meets Store requirements. This step needs to run in a Windows environment because AppX builds require the Windows SDK.

Some things just need to be done in the right place—won't work elsewhere.

Distribution Layer

Finally, publish to GitHub Releases and the Microsoft Store. GitHub Release serves as backup and version tracking, while the Microsoft Store is面向终端用户.

Everything is ready, just needs this final push.

Overall Architecture Design

┌─────────────────────────────────────────────────────────────┐
│                    package-release workflow                  │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────────┐    ┌─────────────────┐                │
│  │ resolve_plan    │───▶│   build         │                │
│  │ (Version parsing & skipping) │    │   (MSIX build)    │                │
│  └─────────────────┘    └────────┬────────┘                │
│         │                       │                          │
│         ▼                       ▼                          │
│  ┌─────────────────┐    ┌─────────────────┐                │
│  │ skip_summary    │    │   publish       │                │
│  │ (Skip report)   │    │   (Publish)     │                │
│  └─────────────────┘    └────────┬────────┘                │
│                                 │                          │
│                                 ▼                          │
│                        ┌─────────────────┐                 │
│                        │ publish_store   │                 │
│                        │ (Store publish) │                 │
│                        └─────────────────┘                 │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The entire process is driven by GitHub Actions and can be triggered on a schedule (every 4 hours) or manually. When manually triggered, you can specify specific desktop and server versions, or force a rebuild.

Actually, once every four hours isn't that frequent—code updates vary in speed, but this approach is safer.

Implementation Details

1. Build Plan Resolution

The first step is to determine whether a new version needs to be built. We need to parse the current versions of desktop and server components, then check whether a corresponding release already exists.

// scripts/resolve-dispatch-build-plan.mjs
export async function resolveDispatchBuildPlan({
  eventName,
  eventPayload,
  desktopAzureSasUrl,
  serverAzureSasUrl,
}) {
  // Parse trigger inputs
  const trigger = normalizeTriggerInputs({ eventName, eventPayload });

  // Parse desktop and server versions from Azure index
  const [desktopRelease, serverRelease] = await Promise.all([
    resolveIndexRelease({ 
      azureSasUrl: desktopAzureSasUrl,
      platformId: 'win-x64' 
    }),
    resolveIndexRelease({ 
      azureSasUrl: serverAzureSasUrl,
      platformId: 'win-x64' 
    })
  ]);

  // Generate release tag
  const desktopTag = normalizeGitTag(desktopRelease.version);
  const releaseTag = deriveStoreReleaseTag(desktopRelease.version, serverRelease.version);
  // For example: desktop-v1.2.3-server-v4.5.6

  // Check if already exists (avoid duplicate builds)
  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 }
  };
}
Enter fullscreen mode Exit fullscreen mode

The key in this step is the skip logic—if the same version combination has already been built, there's no need to run the entire build process again. This saves CI costs and time.

Doing the same thing repeatedly probably doesn't make much sense. After all, time and resources are limited.

2. Workspace Preparation

Once you've decided to build, you need to prepare a clean build environment. We use git worktree to check out code from a specific tag.

// scripts/prepare-packaging-workspace.mjs
export async function preparePackagingWorkspace({
  planPath,
  platformId,
  workspacePath,
  desktopSourcePath
}) {
  // Use git worktree to check out desktop code from specific tag
  await runCommand('git', [
    '-C', resolvedDesktopSourcePath,
    'worktree', 'add', '--detach',
    desktopWorkspace,
    `refs/tags/${plan.upstream.desktop.tag}`
  ]);

  // Validate workspace
  const validation = await validateDesktopWorkspace({
    desktopWorkspace,
    storePackageConfig
  });

  // Create workspace manifest
  const workspaceManifest = {
    desktopWorkspace,
    runtimeInjectionRoot: validation.runtimeRoot,
    desktopTag: plan.upstream.desktop.tag,
    desktopRef,
  };

  return workspaceManifest;
}
Enter fullscreen mode Exit fullscreen mode

The advantage of using worktree is that it doesn't affect the main working directory, builds can run in parallel, and it's easy to clean up after the build completes.

After all, no one wants their main working directory messed up because of a build.

3. Server Payload Injection

This step downloads and injects the server payload into the correct location.

// scripts/stage-server-payload.mjs
export async function stageServerPayload({
  planPath,
  workspacePath,
  platformId,
  azureSasUrl
}) {
  // Download server payload from Azure index
  const assetSource = resolveAssetDownloadUrl({ asset, sasUrl: azureSasUrl });
  await downloadFromSource({ 
    sourceUrl: assetSource, 
    destinationPath: downloadPath 
  });

  // Extract and validate
  await extractArchive(downloadPath, extractionPath);
  const runtimeRoot = await resolveRuntimeRoot(extractionPath);
  const validation = await validateServerPayloadRoot(runtimeRoot, platformId);

  // Inject into packaging runtime layout
  // Target path is resources/portable-fixed/current
  // This path will be mapped to extra/portable-fixed/current in AppX
  await copyDir(runtimeRoot, targetPath);
}
Enter fullscreen mode Exit fullscreen mode

The key is the path mapping—resources/portable-fixed/current gets packaged to extra/portable-fixed/current in the AppX, so the application detects the local runtime at startup and enters offline mode.

Paths are unforgiving—one wrong character and it won't be found.

4. MSIX Build

With the prepared workspace and server payload, you can build the MSIX package.

// scripts/build-appx.mjs
export async function buildAppx({
  planPath,
  workspacePath,
  platformId
}) {
  // Generate Store-specific electron-builder config overlay
  const overlayConfig = await writeStoreElectronBuilderConfig({
    desktopWorkspace: workspaceManifest.desktopWorkspace,
    sourceConfigPath: storePackageConfig.desktop.electronBuilderConfigPath,
    outputConfigPath: 'electron-builder.store.yml'
  });

  // Run desktop build command
  await runShellCommand(
    buildDesktopStoreCommand(overlayConfig.outputPath, desktopScripts),
    workspaceManifest.desktopWorkspace
  );

  // Collect MSIX output
  const storeOutputs = await findStoreOutputs(pkgDirectory);
  const artifactPath = path.join(
    workspaceManifest.outputDirectory, 
    artifactFileName
  );
  await copySingleFile(primaryOutput, artifactPath);
}
Enter fullscreen mode Exit fullscreen mode

The electron-builder configuration needs to include Store-specific metadata like package identity, publisher display name, etc. This information will be used during Store submission.

If the configuration is wrong, the package won't build. Nothing more to say about that.

5. Publishing to GitHub

After the build completes, you need to publish the artifacts to GitHub Releases.

// scripts/publish-release.mjs
export async function publishRelease({
  planPath,
  artifactsDir,
  outputDir,
  forceDryRun
}) {
  // Build publication artifact manifest
  const publicationArtifacts = await buildPublicationArtifacts({
    plan,
    artifactsDir,
    outputDir
  });

  if (!dryRun) {
    // Create or update GitHub Release
    const releaseResult = await upsertReleaseNotes(
      plan.release.repository,
      plan.release.tag,
      token,
      { name, body }
    );

    // Upload assets
    for (const upload of publicationArtifacts.uploads) {
      await uploadReleaseAsset({
        release: releaseResult.release,
        filePath: upload.filePath,
        fileName: upload.fileName
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

GitHub Release serves as version tracking and backup—even if the Store publication fails, the build artifacts are preserved.

It's always good to have a backup plan. What if you need to roll back someday?

6. Publishing to Microsoft Store

The final step is publishing to the Store using the Microsoft Store CLI.

# .github/workflows/package-release.yml
publish_store:
  runs-on: windows-latest
  steps:
    - name: Configure Microsoft Store CLI
      uses: microsoft/microsoft-store-apppublisher@v1.2

    - name: Publish MSIX packages to Store
      shell: pwsh
      run: |
        msstore reconfigure --tenantId $env:AZURE_AD_TENANT_ID ...
        msstore publish "$($package.FullName)" -id $env:MICROSOFT_STORE_PRODUCT_ID
Enter fullscreen mode Exit fullscreen mode

This step needs to run on a Windows runner because the Microsoft Store CLI requires a Windows environment.

Some things just need to be done in the right place—won't work in a different environment.

Practical Guide

Configuration Files

The Store package configuration file defines package identity and build settings:

{
  "packageIdentity": {
    "displayName": "Hagicode",
    "publisherDisplayName": "newbe36524",
    "publisher": "CN=8B6C8A94-AAE5-4C8B-9202-A29EA42B042F",
    "identityName": "newbe36524.Hagicode",
    "backgroundColor": "transparent",
    "languages": ["en-US", "zh-CN", "zh-Hant"]
  },
  "supportedWindowsTargets": ["win-x64"],
  "desktop": {
    "submodulePath": "inputs/hagicode-desktop",
    "electronBuilderConfigPath": "electron-builder.yml",
    "buildScript": "build:appx",
    "runtimeInjectionPath": "resources/portable-fixed/current"
  }
}
Enter fullscreen mode Exit fullscreen mode

Configuration is painful to change, so it's best to get it right the first time.

Workflow Triggers

Can be triggered on schedule or manually:

on:
  schedule:
    - cron: '0 */4 * * *'  # Run every 4 hours
  workflow_dispatch:
    inputs:
      desktop_version:
        description: 'Desktop version selector'
        required: false
      server_version:
        description: 'Server version selector'
        required: false
      force_rebuild:
        description: 'Force rebuild even if exists'
        type: boolean
      dry_run:
        description: 'Build only, do not publish'
        type: boolean
Enter fullscreen mode Exit fullscreen mode

Automatic or manual, as long as it runs in the end.

Key Considerations

  1. Git Tag Requirements: The desktop repository must contain tags corresponding to release versions (e.g., v1.2.3), otherwise the build will fail

  2. Azure SAS URL: Need to configure DESKTOP_AZURE_SAS_URL and SERVER_AZURE_SAS_URL environment variables to access the index

  3. Microsoft Store Credentials: Need to configure the following secrets:

    • AZURE_AD_APPLICATION_CLIENT_ID
    • AZURE_AD_APPLICATION_SECRET
    • AZURE_AD_TENANT_ID
    • SELLER_ID
    • MICROSOFT_STORE_PRODUCT_ID
  4. Runtime Validation: Server payload must contain required runtime files, otherwise packaging will fail

  5. Windows Runner: AppX builds and Store publishing need to run on Windows runners

  6. Duplicate Detection: Scheduled runs check for existing release tags to avoid unnecessary builds

We've probably stepped on all these pits, so writing them down as a reminder. After all, who wants to make the same mistakes twice?

Summary

Windows Store automated deployment looks complex, but when broken down into independent steps, each step isn't difficult. The key is:

  • Use git worktree to manage build environments
  • Inject server payload into the correct path
  • Use electron-builder to generate MSIX packages
  • Use Store CLI for final publishing

This solution has been running stably in the HagiCode project for several months, basically achieving the goal of "full automation"—except for initial credential and setup configuration, subsequent version releases require no manual intervention.

Actually, many things are like this—they look difficult but are straightforward to do. It just takes some patience and experimentation.

If you're working on similar automation, I hope this article provides some reference. Of course, every project is different and may need adjustments based on specific requirements.

After all, all roads lead to Rome—some roads are just easier to travel than others...

References

Original Article & License

Thanks for reading. If this article helped, consider liking, bookmarking, or sharing it.
This article was created with AI assistance and reviewed by the author before publication.

Top comments (0)