name: Submit Desktop to Microsoft Store on: workflow_call: inputs: release_tag: required: true type: string store_environment: required: true type: string submit: required: true type: boolean target_commit: required: true type: string verify_release_tag: default: true required: false type: boolean workflow_dispatch: inputs: release_tag: description: Plain stable tag to package, such as v1.2.3 required: true type: string target_commit: description: Commit expected to be referenced by the stable tag required: true type: string store_environment: description: Complete Partner Center account and application profile options: - microsoft-store-test - microsoft-store-production required: true type: choice submit: default: false description: Submit the validated package to Partner Center required: true type: boolean verify_release_tag: default: true description: Require the release tag to resolve to the target commit required: true type: boolean concurrency: group: desktop-store-${{ inputs.store_environment }}-${{ inputs.release_tag }} cancel-in-progress: false permissions: contents: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-and-submit: name: Build and submit stable Store package environment: ${{ inputs.store_environment }} runs-on: windows-latest timeout-minutes: 60 env: TUTTI_DESKTOP_RELEASE_TAG: ${{ inputs.release_tag }} TUTTI_DESKTOP_RELEASE_TARGET: ${{ inputs.target_commit }} TUTTI_MICROSOFT_STORE_PRODUCT_ID: ${{ vars.TUTTI_MICROSOFT_STORE_PRODUCT_ID }} TUTTI_STORE_APPLICATION_ID: ${{ vars.TUTTI_STORE_APPLICATION_ID }} TUTTI_STORE_DISPLAY_NAME: ${{ vars.TUTTI_STORE_DISPLAY_NAME }} TUTTI_STORE_IDENTITY_NAME: ${{ vars.TUTTI_STORE_IDENTITY_NAME }} TUTTI_STORE_PUBLISHER: ${{ vars.TUTTI_STORE_PUBLISHER }} TUTTI_STORE_PUBLISHER_DISPLAY_NAME: ${{ vars.TUTTI_STORE_PUBLISHER_DISPLAY_NAME }} TUTTI_STORE_MSIX_MIN_VERSION: 10.0.17763.0 steps: - name: Validate stable submission request shell: pwsh run: | $ErrorActionPreference = 'Stop' if ($env:TUTTI_DESKTOP_RELEASE_TAG -notmatch '^v(?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$') { throw "Microsoft Store submission requires a plain stable tag, got $env:TUTTI_DESKTOP_RELEASE_TAG" } $versionParts = @($Matches.version.Split('.') | ForEach-Object { [int]$_ }) if ($versionParts.Where({ $_ -gt 65535 }).Count -gt 0) { throw "Microsoft Store version components must not exceed 65535: $($Matches.version)" } "TUTTI_DESKTOP_BUILD_VERSION=$($Matches.version)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append $required = @( 'TUTTI_MICROSOFT_STORE_PRODUCT_ID', 'TUTTI_STORE_APPLICATION_ID', 'TUTTI_STORE_DISPLAY_NAME', 'TUTTI_STORE_IDENTITY_NAME', 'TUTTI_STORE_PUBLISHER', 'TUTTI_STORE_PUBLISHER_DISPLAY_NAME' ) $missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) if ($missing.Count -gt 0) { throw "Missing Microsoft Store environment variables: $($missing -join ', ')" } - name: Checkout release target uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ inputs.target_commit }} - name: Verify stable tag points to release target if: ${{ inputs.verify_release_tag }} shell: pwsh run: | $ErrorActionPreference = 'Stop' $tagCommit = (git rev-list -n 1 "refs/tags/$env:TUTTI_DESKTOP_RELEASE_TAG").Trim() $headCommit = (git rev-parse HEAD).Trim() if (-not $tagCommit) { throw "Tag $env:TUTTI_DESKTOP_RELEASE_TAG was not found" } if ($tagCommit -ne $headCommit) { throw "Tag $env:TUTTI_DESKTOP_RELEASE_TAG points to $tagCommit, expected $headCommit" } - name: Setup pnpm uses: pnpm/action-setup@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version-file: .node-version cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Setup Go uses: actions/setup-go@v6 with: go-version: "1.24.5" cache-dependency-path: | go.work go.work.sum packages/agent/daemon/go.mod packages/agent/daemon/go.sum services/tuttid/go.mod services/tuttid/go.sum - name: Install dependencies shell: pwsh run: pnpm install --frozen-lockfile - name: Install Electron runtime shell: pwsh run: pnpm --filter @tutti-os/desktop exec install-electron - name: Align package version with stable tag working-directory: apps/desktop shell: pwsh run: node scripts/apply-ci-release-version.mjs "$env:TUTTI_DESKTOP_RELEASE_TAG" - name: Build Windows Store package shell: pwsh env: CSC_IDENTITY_AUTO_DISCOVERY: "false" run: pnpm --filter @tutti-os/desktop build:win:store - id: package name: Validate Store package identity and version shell: pwsh run: | $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem $packages = @(Get-ChildItem 'apps/desktop/dist' -Filter '*.appx' -File) if ($packages.Count -ne 1) { throw "Expected exactly one AppX package, found $($packages.Count)" } $package = $packages[0] $manifestRoot = Join-Path $env:RUNNER_TEMP 'tutti-store-manifest' if (Test-Path $manifestRoot) { Remove-Item -Recurse -Force $manifestRoot } [System.IO.Compression.ZipFile]::ExtractToDirectory($package.FullName, $manifestRoot) [xml]$manifest = Get-Content (Join-Path $manifestRoot 'AppxManifest.xml') -Raw $identity = $manifest.Package.Identity $identityName = $identity.GetAttribute('Name') $publisher = $identity.GetAttribute('Publisher') $version = $identity.GetAttribute('Version') $architecture = $identity.GetAttribute('ProcessorArchitecture') $minVersion = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Dependencies']/*[local-name()='TargetDeviceFamily']").GetAttribute('MinVersion') $maxVersionTested = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Dependencies']/*[local-name()='TargetDeviceFamily']").GetAttribute('MaxVersionTested') $expectedVersion = "$env:TUTTI_DESKTOP_BUILD_VERSION.0" try { $parsedMinVersion = [version]$minVersion $parsedMaxVersionTested = [version]$maxVersionTested $requiredMinVersion = [version]$env:TUTTI_STORE_MSIX_MIN_VERSION } catch { throw "Invalid Store package Windows version metadata: MinVersion=$minVersion, MaxVersionTested=$maxVersionTested" } $packageDisplayName = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Properties']/*[local-name()='DisplayName']").InnerText $application = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Applications']/*[local-name()='Application']") $applicationId = $application.GetAttribute('Id') $executable = $application.GetAttribute('Executable') $entryPoint = $application.GetAttribute('EntryPoint') $visualElements = $application.SelectSingleNode("*[local-name()='VisualElements']") $applicationDisplayName = $visualElements.GetAttribute('DisplayName') $applicationBackgroundColor = $visualElements.GetAttribute('BackgroundColor') $applicationAppListEntry = $visualElements.GetAttribute('AppListEntry') $desktopShortcut = $application.SelectSingleNode("*[local-name()='Extensions']/*[local-name()='Extension' and @Category='windows.shortcut']/*[local-name()='Shortcut']") if ($identityName -ne $env:TUTTI_STORE_IDENTITY_NAME) { throw "Store identity mismatch: $identityName" } if ($publisher -ne $env:TUTTI_STORE_PUBLISHER) { throw "Store publisher mismatch: $publisher" } if ($version -ne $expectedVersion) { throw "Store version mismatch: $version, expected $expectedVersion" } if ($architecture -ne 'x64') { throw "Store architecture mismatch: $architecture" } if ($parsedMinVersion -lt $requiredMinVersion) { throw "Store package MinVersion $minVersion is below the Microsoft Store MSIX floor $env:TUTTI_STORE_MSIX_MIN_VERSION" } if ($parsedMaxVersionTested -lt $parsedMinVersion) { throw "Store package MaxVersionTested $maxVersionTested is below MinVersion $minVersion" } if ($packageDisplayName -ne $env:TUTTI_STORE_DISPLAY_NAME) { throw "Store package display name mismatch: $packageDisplayName" } if ($applicationId -ne $env:TUTTI_STORE_APPLICATION_ID) { throw "Store application id mismatch: $applicationId" } if ($executable -ne 'app\Tutti.exe') { throw "Store executable mismatch: $executable" } if ($entryPoint -ne 'Windows.FullTrustApplication') { throw "Store entry point mismatch: $entryPoint" } if ($applicationDisplayName -ne 'Tutti') { throw "Installed application display name mismatch: $applicationDisplayName" } if ($applicationBackgroundColor -ne 'transparent') { throw "Installed application tile background must be transparent: $applicationBackgroundColor" } if ($applicationAppListEntry -ne 'default') { throw "Installed application must appear in the Start menu: $applicationAppListEntry" } if ($null -eq $desktopShortcut) { throw "Store manifest does not declare the Tutti desktop shortcut" } if ($desktopShortcut.GetAttribute('File') -ne '$(Desktop)\Tutti.lnk') { throw "Store desktop shortcut path mismatch: $($desktopShortcut.GetAttribute('File'))" } if ($desktopShortcut.GetAttribute('Icon') -ne 'app\Tutti.exe') { throw "Store desktop shortcut icon mismatch: $($desktopShortcut.GetAttribute('Icon'))" } $storeAssets = @( 'StoreLogo.png', 'Square44x44Logo.png', 'Square150x150Logo.png', 'Wide310x150Logo.png' ) foreach ($assetName in $storeAssets) { $sourceAsset = Join-Path $PWD "apps\desktop\build\appx\$assetName" $packagedAsset = Join-Path $manifestRoot "assets\$assetName" if (-not (Test-Path -LiteralPath $packagedAsset -PathType Leaf)) { throw "Store package is missing branded asset: $assetName" } $sourceHash = (Get-FileHash -LiteralPath $sourceAsset -Algorithm SHA256).Hash $packagedHash = (Get-FileHash -LiteralPath $packagedAsset -Algorithm SHA256).Hash if ($packagedHash -ne $sourceHash) { throw "Store package did not use branded asset: $assetName" } } $loginProtocol = $manifest.SelectSingleNode("//*[local-name()='Protocol' and @Name='tutti']") if ($null -eq $loginProtocol) { throw "Store manifest does not register the tutti login protocol" } $fullTrustCapability = $manifest.SelectSingleNode("//*[local-name()='Capability' and @Name='runFullTrust']") if ($null -eq $fullTrustCapability) { throw "Store manifest does not declare runFullTrust" } $sha256 = (Get-FileHash $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() @{ architecture = $architecture applicationDisplayName = $applicationDisplayName applicationId = $applicationId commit = (git rev-parse HEAD).Trim() executable = $executable identityName = $identityName package = $package.Name packageDisplayName = $packageDisplayName productId = $env:TUTTI_MICROSOFT_STORE_PRODUCT_ID publisher = $publisher minVersion = $minVersion maxVersionTested = $maxVersionTested submissionPackage = [System.IO.Path]::ChangeExtension($package.Name, '.msix') sha256 = $sha256 tag = $env:TUTTI_DESKTOP_RELEASE_TAG version = $version } | ConvertTo-Json | Set-Content 'apps/desktop/dist/store-submission.json' -Encoding utf8 "path=$($package.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "sha256=$sha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "minVersion=$minVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "maxVersionTested=$maxVersionTested" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - id: submission name: Prepare MSIX Store submission package shell: pwsh run: | $ErrorActionPreference = 'Stop' $appxPath = '${{ steps.package.outputs.path }}' $msixPath = [System.IO.Path]::ChangeExtension($appxPath, '.msix') Copy-Item -LiteralPath $appxPath -Destination $msixPath -Force $appxSha256 = (Get-FileHash -LiteralPath $appxPath -Algorithm SHA256).Hash.ToLowerInvariant() $msixSha256 = (Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($appxSha256 -ne $msixSha256) { throw "MSIX submission alias changed the validated package bytes" } "path=$msixPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "sha256=$msixSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Upload validated Store submission artifact uses: actions/upload-artifact@v7 with: name: tutti-desktop-store-${{ inputs.release_tag }} path: | apps/desktop/dist/*.appx apps/desktop/dist/*.msix apps/desktop/dist/store-submission.json if-no-files-found: error retention-days: 30 - name: Setup Microsoft Store Developer CLI if: ${{ inputs.submit }} uses: microsoft/microsoft-store-apppublisher@v1.1 with: version: v0.4.1 - name: Authenticate Partner Center if: ${{ inputs.submit }} shell: pwsh env: MICROSOFT_STORE_CLIENT_ID: ${{ secrets.MICROSOFT_STORE_CLIENT_ID }} MICROSOFT_STORE_CLIENT_SECRET: ${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }} MICROSOFT_STORE_SELLER_ID: ${{ secrets.MICROSOFT_STORE_SELLER_ID }} MICROSOFT_STORE_TENANT_ID: ${{ secrets.MICROSOFT_STORE_TENANT_ID }} run: | $ErrorActionPreference = 'Stop' $required = @( 'MICROSOFT_STORE_CLIENT_ID', 'MICROSOFT_STORE_CLIENT_SECRET', 'MICROSOFT_STORE_SELLER_ID', 'MICROSOFT_STORE_TENANT_ID' ) $missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) if ($missing.Count -gt 0) { throw "Missing Partner Center credentials: $($missing -join ', ')" } msstore reconfigure ` --tenantId $env:MICROSOFT_STORE_TENANT_ID ` --sellerId $env:MICROSOFT_STORE_SELLER_ID ` --clientId $env:MICROSOFT_STORE_CLIENT_ID ` --clientSecret $env:MICROSOFT_STORE_CLIENT_SECRET - name: Submit package for Microsoft certification if: ${{ inputs.submit }} shell: pwsh run: | $ErrorActionPreference = 'Stop' $msixPath = '${{ steps.submission.outputs.path }}' $packageSize = (Get-Item -LiteralPath $msixPath).Length "Microsoft Store upload package size: $packageSize bytes" msstore publish $msixPath -id "$env:TUTTI_MICROSOFT_STORE_PRODUCT_ID" -ut 600 - name: Record submission summary shell: pwsh run: | @" ## Microsoft Store package - Environment: `${{ inputs.store_environment }}` - Product ID: `$env:TUTTI_MICROSOFT_STORE_PRODUCT_ID` - Tag: `$env:TUTTI_DESKTOP_RELEASE_TAG` - Commit: `$(git rev-parse HEAD)` - Store version: `${{ steps.package.outputs.version }}` - Windows MinVersion: `${{ steps.package.outputs.minVersion }}` - MaxVersionTested: `${{ steps.package.outputs.maxVersionTested }}` - SHA-256: `${{ steps.package.outputs.sha256 }}` - MSIX SHA-256: `${{ steps.submission.outputs.sha256 }}` - Submitted: `${{ inputs.submit }}` The workflow stops after Partner Center accepts the submission request; it does not wait for certification. "@ | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append