Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 68 additions & 13 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Release version (required, e.g. 2026.1.0)'
description: 'Base release version (required, e.g. 2026.3.2)'
required: true
type: string
channel:
description: 'Release channel'
required: false
type: choice
options:
- stable
- beta
default: stable
beta-number:
description: 'Beta number (>= 1, used when channel is beta)'
required: false
type: number
default: 1
draft-release:
description: 'Create the GitHub Release as a draft'
required: true
Expand Down Expand Up @@ -33,6 +47,8 @@ jobs:
package-env: ${{ steps.info.outputs.package-env }}
package-version: ${{ steps.info.outputs.package-version }}
onedrive-version: ${{ steps.info.outputs.onedrive-version }}
release-channel: ${{ steps.info.outputs.release-channel }}
is-prerelease: ${{ steps.info.outputs.is-prerelease }}
draft-release: ${{ steps.info.outputs.draft-release }}
skip-publish: ${{ steps.info.outputs.skip-publish }}
dry-run: ${{ steps.info.outputs.dry-run }}
Expand Down Expand Up @@ -65,22 +81,61 @@ jobs:
$DryRun = $true # force dry run when publishing outside production environment
}

$PackageVersion = '${{ inputs.version }}'
if ([string]::IsNullOrWhiteSpace($PackageVersion)) {
$BaseVersion = '${{ inputs.version }}'.Trim().TrimStart('v', 'V')
if ([string]::IsNullOrWhiteSpace($BaseVersion)) {
throw "The workflow_dispatch version input is required."
}
if ($BaseVersion -notmatch '^\d+\.\d+\.\d+(\.\d+)?$') {
throw "Invalid base version '$BaseVersion'. Expected a numeric version such as '2026.3.2' (use the 'channel' and 'beta-number' inputs for beta builds)."
}

echo "package-env=$PackageEnv" >> $Env:GITHUB_OUTPUT
$OneDriveVersion = "$PackageVersion.0"
$BaseSegments = $BaseVersion -split '\.'
$ThreePartBase = if ($BaseSegments.Count -eq 4 -and $BaseSegments[3] -eq '0') {
"$($BaseSegments[0]).$($BaseSegments[1]).$($BaseSegments[2])"
} else {
$BaseVersion
}
$FourPartBase = if ($BaseSegments.Count -eq 3) { "$BaseVersion.0" } else { $BaseVersion }

$Channel = '${{ inputs.channel }}'.Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($Channel)) {
$Channel = 'stable'
}
switch ($Channel) {
'beta' {
$BetaNumber = 0
if (-not [int]::TryParse('${{ inputs.beta-number }}', [ref]$BetaNumber) -or $BetaNumber -lt 1) {
throw "Invalid beta-number '${{ inputs.beta-number }}'. Must be an integer >= 1 when channel is 'beta'."
}
$PrereleaseSuffix = "-beta$BetaNumber"
$IsPrerelease = $true
$ReleaseChannel = "Beta"
}
'stable' {
$PrereleaseSuffix = ""
$IsPrerelease = $false
$ReleaseChannel = "Current"
}
default {
throw "Unsupported channel '$Channel'. Expected 'stable' or 'beta'."
}
}

$PackageVersion = "$ThreePartBase$PrereleaseSuffix"
Comment thread
mamoreau-devolutions marked this conversation as resolved.
$OneDriveVersion = "$FourPartBase$PrereleaseSuffix"

echo "package-env=$PackageEnv" >> $Env:GITHUB_OUTPUT
echo "package-version=$PackageVersion" >> $Env:GITHUB_OUTPUT
echo "onedrive-version=$OneDriveVersion" >> $Env:GITHUB_OUTPUT
echo "release-channel=$ReleaseChannel" >> $Env:GITHUB_OUTPUT
echo "is-prerelease=$($IsPrerelease.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
echo "draft-release=$($DraftRelease.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
echo "skip-publish=$($SkipPublish.ToString().ToLower())" >> $Env:GITHUB_OUTPUT
echo "dry-run=$($DryRun.ToString().ToLower())" >> $Env:GITHUB_OUTPUT

echo "::notice::Environment: $PackageEnv"
echo "::notice::Version: $PackageVersion"
echo "::notice::Channel: $ReleaseChannel (Prerelease: $IsPrerelease)"
echo "::notice::Version: $PackageVersion (OneDrive: $OneDriveVersion)"
echo "::notice::DraftRelease: $DraftRelease"
echo "::notice::DryRun: $DryRun"

Expand Down Expand Up @@ -845,6 +900,7 @@ jobs:
working-directory: output
run: |
$PackageVersion = '${{ needs.preflight.outputs.package-version }}'
$IsPrerelease = [System.Boolean]::Parse('${{ needs.preflight.outputs.is-prerelease }}')
$DraftRelease = [System.Boolean]::Parse('${{ needs.preflight.outputs.draft-release }}')
$DryRun = [System.Boolean]::Parse('${{ needs.preflight.outputs.dry-run }}')

Expand All @@ -855,7 +911,6 @@ jobs:
$ReleaseTag = "v$PackageVersion"
$ReleaseTitle = "UniGetUI v${PackageVersion}"
$Repository = $Env:GITHUB_REPOSITORY
$DraftArg = if ($DraftRelease) { '--draft' } else { $null }

# De-duplicate by basename: release asset names must be unique (e.g. two sizes.md would 422).
$Files = Get-ChildItem -Path . -Recurse -File | Where-Object {
Expand All @@ -864,14 +919,14 @@ jobs:

if ($DryRun) {
Write-Host "Dry Run: skipping GitHub release creation!"
Write-Host "Would create release $ReleaseTag with title '$ReleaseTitle' (draft=$DraftRelease)"
Write-Host "Would create release $ReleaseTag with title '$ReleaseTitle' (draft=$DraftRelease, prerelease=$IsPrerelease)"
$Files | ForEach-Object { Write-Host " - $($_.FullName)" }
} else {
if ($DraftArg) {
& gh release create $ReleaseTag --repo $Repository --title $ReleaseTitle $DraftArg $Files.FullName
} else {
& gh release create $ReleaseTag --repo $Repository --title $ReleaseTitle $Files.FullName
}
$ReleaseArgs = @('release', 'create', $ReleaseTag, '--repo', $Repository, '--title', $ReleaseTitle)
if ($DraftRelease) { $ReleaseArgs += '--draft' }
if ($IsPrerelease) { $ReleaseArgs += @('--prerelease', '--latest=false') }
$ReleaseArgs += $Files.FullName
& gh @ReleaseArgs
}

- name: Check out Devolutions/actions
Expand Down
8 changes: 5 additions & 3 deletions scripts/set-version.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ $ErrorActionPreference = 'Stop'

$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")

# Derive four-part version: 3.3.7 -> 3.3.7.0, 3.4.0-beta1 -> 3.4.0.0
$CleanVersion = ($Version -Split '-')[0] # strip prerelease tag
$Version = $Version.Trim().TrimStart('v', 'V')

# Derive four-part version: 3.3.7 -> 3.3.7.0, 2026.3.2-beta1 -> 2026.3.2.0
$CleanVersion = (($Version -Split '\+')[0] -Split '-')[0] # strip build metadata and prerelease tag
$Parts = $CleanVersion -Split '\.'
while ($Parts.Count -lt 4) { $Parts += '0' }
$FourPartVersion = ($Parts[0..3]) -Join '.'
Expand Down Expand Up @@ -87,7 +89,7 @@ Set-LinesByPrefix -FilePath (Join-Path $RepoRoot "UniGetUI.iss") -Replacements @
}

# --- app.manifest (only the assemblyIdentity version, not manifestVersion) ---
$ManifestPath = Join-Path $RepoRoot "src" "UniGetUI" "app.manifest"
$ManifestPath = Join-Path $RepoRoot "src" "UniGetUI.Avalonia" "app.manifest"
if (Test-Path $ManifestPath) {
$content = Get-Content $ManifestPath -Raw -Encoding utf8BOM
$content = $content -Replace '(?s)(<assemblyIdentity\b.*?\bversion\s*=\s*")[^"]*(")', "`${1}$FourPartVersion`${2}"
Expand Down
199 changes: 192 additions & 7 deletions src/Shared/AutoUpdater.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,196 @@ internal static ProductInfoFile SelectInstallerFile(List<ProductInfoFile> files)
return match;
}

internal static Version ParseVersionOrFallback(string rawVersion, Version fallbackVersion)
internal static Version ParseVersionOrFallback(
string? rawVersion,
Version fallbackVersion,
Action<string>? logWarning = null
)
{
if (Version.TryParse(rawVersion, out Version? parsed))
if (TryExtractNormalizedCoreVersion(rawVersion, out Version? normalized, out _))
{
return CoreTools.NormalizeVersionForComparison(parsed);
return normalized!;
}

string sanitized = rawVersion.Trim().TrimStart('v', 'V');
if (Version.TryParse(sanitized, out parsed))
string message = $"Could not parse version '{rawVersion}', using fallback '{fallbackVersion}'";
if (logWarning is not null)
{
logWarning(message);
}
else
{
return CoreTools.NormalizeVersionForComparison(parsed);
Logger.Warn(message);
}

Logger.Warn($"Could not parse version '{rawVersion}', using fallback '{fallbackVersion}'");
return fallbackVersion;
}

internal static bool TryParseUpdaterVersion(string? rawVersion, out SemanticVersion version)
{
version = SemanticVersion.Invalid(rawVersion ?? string.Empty);
if (!TryExtractNormalizedCoreVersion(rawVersion, out Version? normalizedCore, out string? preRelease))
{
return false;
}

string canonical = preRelease is not null
? $"{normalizedCore}-{preRelease}"
: normalizedCore!.ToString();

return SemanticVersion.TryParse(canonical, SemVerLabels.CaseInsensitive, out version);
}

internal static SemanticVersion ParseUpdaterVersionOrFallback(
string? rawVersion,
Version fallbackVersion,
Action<string>? logWarning = null
)
{
if (TryParseUpdaterVersion(rawVersion, out SemanticVersion parsed))
{
return parsed;
}

string message = $"Could not parse version '{rawVersion}', using fallback '{fallbackVersion}'";
if (logWarning is not null)
{
logWarning(message);
}
else
{
Logger.Warn(message);
}

return SemanticVersion.TryParse(
fallbackVersion.ToString(),
SemVerLabels.CaseInsensitive,
out SemanticVersion fallback
)
? fallback
: SemanticVersion.Invalid(rawVersion ?? string.Empty);
}

internal static int CompareUpdaterVersions(
string? currentRaw,
string? availableRaw,
Version? currentFallback = null,
Action<string>? logWarning = null
)
{
SemanticVersion current = ParseUpdaterVersionOrFallback(
currentRaw,
currentFallback ?? new Version(0, 0, 0, 0),
logWarning
);
SemanticVersion available = ParseUpdaterVersionOrFallback(
availableRaw,
new Version(0, 0, 0, 0),
logWarning
);
return current.CompareTo(available);
}

internal static bool IsUpdateAvailable(
string? currentRaw,
string? availableRaw,
Version? currentFallback = null,
Action<string>? logWarning = null
) => CompareUpdaterVersions(currentRaw, availableRaw, currentFallback, logWarning) < 0;

internal static bool VersionsMatch(string? a, string? b)
{
if (
TryParseUpdaterVersion(a, out SemanticVersion va)
&& TryParseUpdaterVersion(b, out SemanticVersion vb)
)
{
return va.CompareTo(vb) == 0;
}

string sa = (a ?? string.Empty).Trim().TrimStart('v', 'V');
string sb = (b ?? string.Empty).Trim().TrimStart('v', 'V');
return string.Equals(sa, sb, StringComparison.OrdinalIgnoreCase);
}

internal static bool IsPreReleaseVersion(string? rawVersion) =>
TryParseUpdaterVersion(rawVersion, out SemanticVersion parsed) && parsed.IsPreRelease;

internal static ProductInfoChannel? SelectChannel(
ProductInfoChannel? current,
ProductInfoChannel? beta,
bool useBeta
)
{
if (!useBeta)
{
return current;
}

if (beta is null)
{
return current;
}

if (current is null)
{
return beta;
}

// Prefer Beta only when its version is strictly newer than Current; once stable
// catches up (e.g. 2026.3.2.0 > 2026.3.2-beta3 or 2026.3.2.0 == 2026.3.2.0),
// prefer Current so beta users upgrade seamlessly to the final release.
return IsUpdateAvailable(current.Version, beta.Version) ? beta : current;
}

internal static ProductInfoChannel? SelectChannel(ProductInfoEntry product, bool useBeta) =>
SelectChannel(product.Current, product.Beta, useBeta);

private static bool TryExtractNormalizedCoreVersion(
string? rawVersion,
out Version? normalizedCore,
out string? preRelease
)
{
normalizedCore = null;
preRelease = null;

if (string.IsNullOrWhiteSpace(rawVersion))
{
return false;
}

string sanitized = rawVersion.Trim().TrimStart('v', 'V');
int plusIndex = sanitized.IndexOf('+');
if (plusIndex >= 0)
{
sanitized = sanitized[..plusIndex];
}

int dashIndex = sanitized.IndexOf('-');
string corePart;
if (dashIndex >= 0)
{
corePart = sanitized[..dashIndex];
preRelease = sanitized[(dashIndex + 1)..];
if (string.IsNullOrWhiteSpace(preRelease))
{
return false;
}
}
else
{
corePart = sanitized;
}

if (!Version.TryParse(corePart, out Version? parsed))
{
return false;
}

normalizedCore = CoreTools.NormalizeVersionForComparison(parsed);
return true;
}

internal static bool IsDefaultDevVersion(string? rawVersion)
{
if (string.IsNullOrWhiteSpace(rawVersion))
Expand Down Expand Up @@ -165,6 +338,18 @@ internal static bool GetRegistryBool(RegistryKey? key, string valueName)
}
#endif

internal sealed class ProductInfoEntry
{
public ProductInfoChannel? Current { get; set; }
public ProductInfoChannel? Beta { get; set; }
}

internal sealed class ProductInfoChannel
{
public string Version { get; set; } = string.Empty;
public List<ProductInfoFile> Files { get; set; } = [];
}

internal sealed class ProductInfoFile
{
public string Arch { get; set; } = string.Empty;
Expand Down
Loading
Loading