ci: build Windows Store release artifacts

This commit is contained in:
2026-07-16 22:34:02 +02:00
parent 4321340a23
commit 97dd9b32be
16 changed files with 645 additions and 3 deletions

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap10 rescap">
<Identity
Name="SudosyLabs.Vnidrop"
Publisher="CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB"
Version="__VERSION__"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Vnidrop</DisplayName>
<PublisherDisplayName>Sudosy Labs</PublisherDisplayName>
<Description>Send files directly across your devices.</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-US" />
</Resources>
<Dependencies>
<TargetDeviceFamily
Name="Windows.Desktop"
MinVersion="10.0.19041.0"
MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="VniDrop"
Executable="VniDrop.exe"
uap10:RuntimeBehavior="packagedClassicApp"
uap10:TrustLevel="mediumIL">
<uap:VisualElements
DisplayName="Vnidrop"
Description="Send files directly across your devices."
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png"
BackgroundColor="transparent" />
<Extensions>
<uap:Extension Category="windows.fileTypeAssociation">
<uap:FileTypeAssociation Name="vnidropinvitation">
<uap:DisplayName>VniDrop Invitation</uap:DisplayName>
<uap:Logo>Assets\Square44x44Logo.png</uap:Logo>
<uap:SupportedFileTypes>
<uap:FileType>.vnd</uap:FileType>
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
</Extensions>
</Application>
</Applications>
</Package>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

105
packaging/windows/README.md Normal file
View File

@@ -0,0 +1,105 @@
# Windows Microsoft Store packaging
This directory turns the Compose Desktop Windows app image into the unsigned
MSIX artifacts accepted by Partner Center. Microsoft signs the package after
certification, so this build does not use a PFX, certificate, HSM, or signing
secret.
## Product identity
These values came from the Partner Center Product identity page and are
case-sensitive:
| Field | Value |
| --- | --- |
| Package identity name | SudosyLabs.Vnidrop |
| Publisher | CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB |
| Publisher display name | Sudosy Labs |
| Reserved Store name | Vnidrop |
| Application ID | VniDrop |
| Store ID | 9NJ5Q0FG7TGL |
The package identity name, publisher, and Application ID must remain stable
after the first release. The manifest display name uses the exact reserved Store
name; the product's in-app branding and launcher remain `VniDrop`.
The initial package targets Windows Desktop x64, Windows 10 version 2004
(build 19041) or later. The fourth MSIX version component is reserved by the
Store, so app version 1.2.3 becomes package version 1.2.3.0.
## GitHub Actions
The Windows Store package workflow runs automatically for relevant pull
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch.
Pull requests build and validate without retaining an artifact. Tags and manual
runs retain:
- VniDrop_VERSION_x64.msix
- VniDrop_VERSION_x64.msixupload
- build metadata
- SHA-256 checksums
The preferred Partner Center upload is the msixupload file. It is an upload
envelope containing the x64 MSIX. The MSIX is intentionally unsigned and is not
a public sideloading artifact. Do not attach it to a public GitHub Release
unless an independent production-signing path is added.
The workflow explicitly selects Gobley's release Rust variant and rejects a
package containing the debug native JAR. It also verifies the bundled JVM,
vnidrop.dll, app version, manifest identity, architecture, and launcher after
MakeAppx unpacks the finished package.
## First Store release
Microsoft's current GitHub Actions publishing flow is for updates to an
already-live free product. For the first release:
1. Run this workflow from a release tag or by manual dispatch.
2. Download the retained artifact.
3. Test that exact build on an interactive Windows VM. Local installation needs
an ephemeral development signature trusted only by that VM; this is not a
production signing key.
4. Upload the msixupload file to the current Partner Center draft.
5. Confirm that Partner Center parses the expected identity, version, x64
architecture, Windows.Desktop target, en-US language, and runFullTrust
capability.
6. Complete listing, screenshots, certification notes, and submit.
Use this restricted-capability justification in Submission options:
> VniDrop is a classic JVM desktop application that loads its bundled native
> Rust and JVM libraries and needs normal user-level filesystem and network
> access to transfer user-selected files directly between devices.
After the first release is certified and live, Store publication can be added
as a separate protected job. Keep its Partner Center credentials in a GitHub
Environment, not in this build job:
- AZURE_AD_TENANT_ID
- AZURE_AD_APPLICATION_CLIENT_ID
- AZURE_AD_APPLICATION_SECRET
- SELLER_ID
The Store ID is a non-secret variable.
## Manual build on Windows
From the repository root:
~~~powershell
.\gradlew.bat :shared:jvmTest :desktopApp:createReleaseDistributable -Pvnidrop.version=1.0.0 -Pvnidrop.desktop.rustVariant=release -Pvnidrop.diagnostics.included=false --no-daemon --no-configuration-cache --stacktrace
.\packaging\windows\build-msix.ps1 -Version 1.0.0 -AppImage .\desktopApp\build\compose\binaries\main-release\app\VniDrop -OutputDirectory .\build\release\windows
~~~
The packaging script requires Windows SDK 10.0.26100.0. It uses MakePri to
index the scale-qualified visual assets, then MakeAppx with SHA-256 block maps
and manifest validation enabled.
Microsoft references:
- [MSIX Store package requirements](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/app-package-requirements)
- [Manual desktop MSIX packaging](https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-manual-conversion)
- [MakeAppx](https://learn.microsoft.com/en-us/windows/msix/package/create-app-package-with-makeappx-tool)
- [Uploading MSIX packages](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/upload-app-packages)
- [GitHub Actions Store updates](https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/github-actions)

View File

@@ -0,0 +1,280 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)]
[string] $AppImage,
[Parameter(Mandatory)]
[string] $OutputDirectory,
[string] $WindowsSdkVersion = "10.0.26100.0"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Assert-Condition {
param(
[bool] $Condition,
[string] $Message
)
if (-not $Condition) {
throw $Message
}
}
function Invoke-Checked {
param(
[string] $FilePath,
[string[]] $Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath failed with exit code $LASTEXITCODE"
}
}
function Read-ZipEntry {
param(
[string] $ArchivePath,
[string] $EntryPath
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$entry = $archive.GetEntry($EntryPath)
if ($null -eq $entry) {
throw "$ArchivePath does not contain $EntryPath"
}
$reader = [System.IO.StreamReader]::new($entry.Open())
try {
return $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
finally {
$archive.Dispose()
}
}
function Get-ZipEntryLength {
param(
[string] $ArchivePath,
[string] $EntryPath
)
$archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$entry = $archive.GetEntry($EntryPath)
if ($null -eq $entry) {
throw "$ArchivePath does not contain $EntryPath"
}
return $entry.Length
}
finally {
$archive.Dispose()
}
}
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
throw "MSIX packaging must run on Windows"
}
$versionParts = $Version.Split(".")
Assert-Condition ($versionParts.Count -eq 3) "Version must use MAJOR.MINOR.PATCH"
for ($index = 0; $index -lt $versionParts.Count; $index++) {
$part = $versionParts[$index]
$number = 0
Assert-Condition ([int]::TryParse($part, [ref] $number)) "Version components must be integers"
Assert-Condition ($number.ToString() -eq $part) "Version components must not contain leading zeroes"
Assert-Condition ($number -ge $(if ($index -eq 0) { 1 } else { 0 }) -and $number -le 65535) "Version components must be between 0 and 65535, with a non-zero major"
}
$packageVersion = "$Version.0"
$appImagePath = (Resolve-Path -LiteralPath $AppImage).Path
Assert-Condition (Test-Path -LiteralPath $appImagePath -PathType Container) "App image not found: $AppImage"
Assert-Condition (Test-Path -LiteralPath (Join-Path $appImagePath "VniDrop.exe") -PathType Leaf) "The app image does not contain VniDrop.exe"
Assert-Condition (Test-Path -LiteralPath (Join-Path $appImagePath "runtime\bin\server\jvm.dll") -PathType Leaf) "The app image does not contain its bundled JVM"
Add-Type -AssemblyName System.IO.Compression.FileSystem
$appFiles = @(Get-ChildItem -LiteralPath $appImagePath -Recurse -File)
$debugRustJars = @($appFiles | Where-Object { $_.Name -match "^shared-win32-x86-64-debug-.+\.jar$" })
Assert-Condition ($debugRustJars.Count -eq 0) "The app image contains a debug Rust runtime JAR"
$releaseRustJars = @($appFiles | Where-Object { $_.Name -match "^shared-win32-x86-64-(?!debug-).+\.jar$" })
Assert-Condition ($releaseRustJars.Count -eq 1) "Expected exactly one release Rust runtime JAR"
$nativeDllLength = Get-ZipEntryLength -ArchivePath $releaseRustJars[0].FullName -EntryPath "win32-x86-64/vnidrop.dll"
Assert-Condition ($nativeDllLength -gt 0) "The release Rust runtime JAR contains an empty vnidrop.dll"
$sharedJars = @($appFiles | Where-Object { $_.Name -match "^shared-jvm-.+\.jar$" })
Assert-Condition ($sharedJars.Count -eq 1) "Expected exactly one shared JVM JAR"
$sharedManifest = Read-ZipEntry -ArchivePath $sharedJars[0].FullName -EntryPath "META-INF/MANIFEST.MF"
Assert-Condition ($sharedManifest -match "(?m)^Implementation-Version: $([regex]::Escape($Version))\r?$") "The packaged app version does not match $Version"
$programFilesX86 = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFilesX86)
$makeAppxPath = Join-Path $programFilesX86 "Windows Kits\10\bin\$WindowsSdkVersion\x64\MakeAppx.exe"
$makePriPath = Join-Path $programFilesX86 "Windows Kits\10\bin\$WindowsSdkVersion\x64\MakePri.exe"
Assert-Condition (Test-Path -LiteralPath $makeAppxPath -PathType Leaf) "MakeAppx.exe from Windows SDK $WindowsSdkVersion was not found"
Assert-Condition (Test-Path -LiteralPath $makePriPath -PathType Leaf) "MakePri.exe from Windows SDK $WindowsSdkVersion was not found"
$outputPath = [System.IO.Path]::GetFullPath($OutputDirectory)
[System.IO.Directory]::CreateDirectory($outputPath) | Out-Null
$artifactBaseName = "VniDrop_" + $Version + "_x64"
$msixPath = Join-Path $outputPath "$artifactBaseName.msix"
$uploadPath = Join-Path $outputPath "$artifactBaseName.msixupload"
$buildInfoPath = Join-Path $outputPath "$artifactBaseName.build-info.json"
$checksumsPath = Join-Path $outputPath "SHA256SUMS"
@($msixPath, $uploadPath, $buildInfoPath, $checksumsPath) |
Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Remove-Item -LiteralPath $_ -Force }
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("vnidrop-msix-" + [System.Guid]::NewGuid().ToString("N"))
$packageRoot = Join-Path $stageRoot "package"
$unpackedRoot = Join-Path $stageRoot "unpacked"
$priConfigPath = Join-Path $stageRoot "priconfig.xml"
[System.IO.Directory]::CreateDirectory($packageRoot) | Out-Null
try {
Get-ChildItem -LiteralPath $appImagePath -Force | Copy-Item -Destination $packageRoot -Recurse -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot "Assets") -Destination $packageRoot -Recurse -Force
$manifestTemplate = Get-Content -LiteralPath (Join-Path $PSScriptRoot "AppxManifest.xml") -Raw
Assert-Condition (([regex]::Matches($manifestTemplate, "__VERSION__")).Count -eq 1) "AppxManifest.xml must contain exactly one __VERSION__ placeholder"
$manifestText = $manifestTemplate.Replace("__VERSION__", $packageVersion)
[System.IO.File]::WriteAllText(
(Join-Path $packageRoot "AppxManifest.xml"),
$manifestText,
[System.Text.UTF8Encoding]::new($false)
)
Invoke-Checked -FilePath $makePriPath -Arguments @(
"createconfig",
"/cf", $priConfigPath,
"/dq", "en-US",
"/o"
)
Invoke-Checked -FilePath $makePriPath -Arguments @(
"new",
"/pr", $packageRoot,
"/cf", $priConfigPath,
"/mn", (Join-Path $packageRoot "AppxManifest.xml"),
"/of", (Join-Path $packageRoot "resources.pri"),
"/o"
)
Assert-Condition ((Get-Item -LiteralPath (Join-Path $packageRoot "resources.pri")).Length -gt 0) "MakePri created an empty resources.pri"
Invoke-Checked -FilePath $makeAppxPath -Arguments @(
"pack",
"/v",
"/h", "SHA256",
"/d", $packageRoot,
"/p", $msixPath,
"/o"
)
Invoke-Checked -FilePath $makeAppxPath -Arguments @(
"unpack",
"/v",
"/p", $msixPath,
"/d", $unpackedRoot,
"/o"
)
[xml] $manifest = Get-Content -LiteralPath (Join-Path $unpackedRoot "AppxManifest.xml") -Raw
$namespaces = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
$namespaces.AddNamespace("f", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
$namespaces.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10")
$namespaces.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10")
$namespaces.AddNamespace("rescap", "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities")
$identity = $manifest.SelectSingleNode("/f:Package/f:Identity", $namespaces)
Assert-Condition ($null -ne $identity) "The packed manifest has no Identity"
Assert-Condition ($identity.GetAttribute("Name") -eq "SudosyLabs.Vnidrop") "The packed package identity name is incorrect"
Assert-Condition ($identity.GetAttribute("Publisher") -eq "CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB") "The packed publisher identity is incorrect"
Assert-Condition ($identity.GetAttribute("Version") -eq $packageVersion) "The packed package version is incorrect"
Assert-Condition ($identity.GetAttribute("ProcessorArchitecture") -eq "x64") "The packed package architecture is not x64"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Properties/f:DisplayName", $namespaces).InnerText -eq "Vnidrop") "The packed display name does not match the reserved Store name"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Properties/f:PublisherDisplayName", $namespaces).InnerText -eq "Sudosy Labs") "The packed publisher display name is incorrect"
$targetFamily = $manifest.SelectSingleNode("/f:Package/f:Dependencies/f:TargetDeviceFamily", $namespaces)
Assert-Condition ($null -ne $targetFamily) "The packed manifest has no target device family"
Assert-Condition ($targetFamily.GetAttribute("Name") -eq "Windows.Desktop") "The packed package does not target Windows.Desktop"
Assert-Condition ($null -ne $manifest.SelectSingleNode("/f:Package/f:Capabilities/rescap:Capability[@Name='runFullTrust']", $namespaces)) "The packed package does not declare runFullTrust"
$application = $manifest.SelectSingleNode("/f:Package/f:Applications/f:Application", $namespaces)
Assert-Condition ($null -ne $application) "The packed manifest has no Application"
Assert-Condition ($application.GetAttribute("Id") -eq "VniDrop") "The packed application ID is incorrect"
$executable = $application.GetAttribute("Executable")
Assert-Condition ($executable -eq "VniDrop.exe") "The packed manifest executable is incorrect"
Assert-Condition ($application.GetAttribute("RuntimeBehavior", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10") -eq "packagedClassicApp") "The packed runtime behavior is incorrect"
Assert-Condition ($application.GetAttribute("TrustLevel", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10") -eq "mediumIL") "The packed trust level is incorrect"
Assert-Condition ($manifest.SelectSingleNode("/f:Package/f:Applications/f:Application/f:Extensions/uap:Extension/uap:FileTypeAssociation/uap:SupportedFileTypes/uap:FileType[text()='.vnd']", $namespaces) -ne $null) "The packed package is missing the .vnd file association"
Assert-Condition (Test-Path -LiteralPath (Join-Path $unpackedRoot $executable) -PathType Leaf) "The packed executable is missing"
Assert-Condition (Test-Path -LiteralPath (Join-Path $unpackedRoot "resources.pri") -PathType Leaf) "The packed resource index is missing"
}
finally {
if (Test-Path -LiteralPath $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force
}
}
$temporaryZip = Join-Path $outputPath "$artifactBaseName.zip"
if (Test-Path -LiteralPath $temporaryZip) {
Remove-Item -LiteralPath $temporaryZip -Force
}
Compress-Archive -LiteralPath $msixPath -DestinationPath $temporaryZip -CompressionLevel Optimal
Move-Item -LiteralPath $temporaryZip -Destination $uploadPath
$repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..\..")).Path
$wrapperProperties = Get-Content -LiteralPath (Join-Path $repoRoot "gradle\wrapper\gradle-wrapper.properties")
$gradleDistribution = $wrapperProperties | Where-Object { $_.StartsWith("distributionUrl=") } | Select-Object -First 1
$sourceCommit = [System.Environment]::GetEnvironmentVariable("GITHUB_SHA")
if ([string]::IsNullOrWhiteSpace($sourceCommit)) {
$sourceCommit = "local"
}
$sourceRef = [System.Environment]::GetEnvironmentVariable("GITHUB_REF")
if ([string]::IsNullOrWhiteSpace($sourceRef)) {
$sourceRef = "local"
}
$buildInfo = [ordered] @{
appVersion = $Version
packageVersion = $packageVersion
architecture = "x64"
identityName = "SudosyLabs.Vnidrop"
publisher = "CN=6456DC8E-2C31-44BD-AACC-2E6813C833CB"
storeId = "9NJ5Q0FG7TGL"
sourceCommit = $sourceCommit
sourceRef = $sourceRef
runnerImage = [System.Environment]::GetEnvironmentVariable("ImageOS")
runnerImageVersion = [System.Environment]::GetEnvironmentVariable("ImageVersion")
javaVersion = ((& java --version | Select-Object -First 1) | Out-String).Trim()
rustVersion = ((& rustc --version) | Out-String).Trim()
cargoVersion = ((& cargo --version) | Out-String).Trim()
gradleDistribution = $gradleDistribution
windowsSdkVersion = $WindowsSdkVersion
makeAppxVersion = (Get-Item -LiteralPath $makeAppxPath).VersionInfo.FileVersion
makePriVersion = (Get-Item -LiteralPath $makePriPath).VersionInfo.FileVersion
unsignedForMicrosoftStore = $true
builtAtUtc = [System.DateTimeOffset]::UtcNow.ToString("O")
}
[System.IO.File]::WriteAllText(
$buildInfoPath,
($buildInfo | ConvertTo-Json -Depth 4),
[System.Text.UTF8Encoding]::new($false)
)
$checksumTargets = @($msixPath, $uploadPath, $buildInfoPath)
[string[]] $checksumLines = $checksumTargets | ForEach-Object {
$hash = Get-FileHash -LiteralPath $_ -Algorithm SHA256
$hash.Hash.ToLowerInvariant() + " " + [System.IO.Path]::GetFileName($_)
}
[System.IO.File]::WriteAllLines($checksumsPath, $checksumLines, [System.Text.Encoding]::ASCII)
Write-Host "Created unsigned Microsoft Store artifacts:"
Write-Host " $msixPath"
Write-Host " $uploadPath"
Write-Host " $checksumsPath"