Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,18 +281,19 @@ jobs:
- name: Patch MSIX manifest metadata
shell: pwsh
run: |
$version = "${{ needs.test.outputs.majorMinorPatch }}.0"
# NOTE: Identity/Version is auto-synced from /p:Version by the SyncAppxManifestVersion
# target in OpenClaw.Tray.WinUI.csproj during the MSIX build below; we only patch the
# alpha/non-alpha identity and display name here.
$isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true"
$identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" }
$displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" }
$manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest"
[xml]$xml = Get-Content $manifest
$xml.Package.Identity.Name = $identityName
$xml.Package.Identity.Version = $version
$xml.Package.Properties.DisplayName = $displayName
$xml.Package.Applications.Application.VisualElements.DisplayName = $displayName
$xml.Save((Resolve-Path $manifest))
Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version"
Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName' (Version will be synced from /p:Version by msbuild target)"

- name: Build MSIX Package
run: >
Expand Down
26 changes: 26 additions & 0 deletions docs/VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n
2. **Let GitVersion and CI control the version** - the csproj's `<Version>` is just a fallback for local development builds
3. **Test version detection** - after building, check the EXE properties to ensure FileVersion matches expectations
4. **Use semantic versioning** - tags should follow `v{major}.{minor}.{patch}` format (e.g., `v0.4.0`)
5. **Use `OpenClaw.Shared.AppVersionInfo` for any user-visible or wire-exposed version string** - never re-roll
`typeof(...).Assembly.GetName().Version` or hardcode literals like `"v0.1.0"`. `AppVersionInfo` is the single
source of truth driven by `<Version>`, used by the About page, Update dialog, support-context dump,
`device.info` capability, MCP `serverVersion` handshake, and the update-check diagnostics.

## Runtime Version Resolution (AppVersionInfo)

`src/OpenClaw.Shared/AppVersionInfo.cs` exposes:

- `AppVersionInfo.Version` → bare string, e.g. `"0.4.7"`
- `AppVersionInfo.DisplayVersion` → `"v"` prefix, e.g. `"v0.4.7"`

It resolves the version by:

1. Looking for the `OpenClaw.Tray.WinUI` assembly in the current `AppDomain` (so `dotnet test` and CLI siblings
still report the tray's version rather than the testhost / dotnet host).
2. Falling back to `Assembly.GetEntryAssembly()`, then to the Shared assembly.
3. Reading `AssemblyInformationalVersionAttribute` (preferred) or `AssemblyVersion`.
4. Stripping SourceLink build metadata (`+abc123`) **and** the SemVer pre-release suffix (`-beta.1`) so the
displayed value matches what Updatum compares (Updatum reads the numeric `AssemblyVersion` only).

For tests that need a deterministic value regardless of host process, set the `internal` test hook:

```csharp
AppVersionInfo.TestOverride = "9.9.9";
```

## References

Expand Down
101 changes: 101 additions & 0 deletions src/OpenClaw.Shared/AppVersionInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;
using System.Reflection;

namespace OpenClaw.Shared;

/// <summary>
/// Single source of truth for the OpenClaw Companion app version that is
/// surfaced to users. Reads <see cref="AssemblyInformationalVersionAttribute"/>
/// (or falls back to <c>AssemblyVersion</c>) from the tray executable so every
/// UI/diagnostic/handshake site reports the same number driven by the csproj
/// <c>&lt;Version&gt;</c> property.
/// </summary>
/// <remarks>
/// Under <c>dotnet test</c> and inside CLI siblings, <see cref="Assembly.GetEntryAssembly"/>
/// is the host process (testhost / dotnet), not the tray exe — so we first
/// search the current <see cref="AppDomain"/> for the tray assembly by name.
/// Tests that need a deterministic value can set <see cref="TestOverride"/>.
/// </remarks>
public static class AppVersionInfo
{
private const string TrayAssemblyName = "OpenClaw.Tray.WinUI";

private static readonly string _version = ResolveVersion();

/// <summary>
/// Test-only override. When non-null, <see cref="Version"/> returns this
/// value instead of the reflected one, giving tests a deterministic
/// version string regardless of the host process.
/// </summary>
internal static string? TestOverride { get; set; }

/// <summary>Bare version string, e.g. <c>"0.4.7"</c>.</summary>
public static string Version => TestOverride ?? _version;

/// <summary>Version prefixed with <c>v</c>, e.g. <c>"v0.4.7"</c>.</summary>
public static string DisplayVersion => "v" + Version;

private static string ResolveVersion()
{
try
{
var assembly = FindTrayAssembly()
?? Assembly.GetEntryAssembly()
?? typeof(AppVersionInfo).Assembly;

var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
return NormalizeSemVer(informational);
}

var name = assembly.GetName().Version;
if (name != null)
{
// System.Version uses -1 for unspecified components; coerce to 0.
var build = Math.Max(0, name.Build);
var revision = Math.Max(0, name.Revision);
return revision == 0
? $"{name.Major}.{name.Minor}.{build}"
: $"{name.Major}.{name.Minor}.{build}.{revision}";
}

return "0.0.0";
}
catch
{
// Never let the type initializer fail — a TypeInitializationException
// would poison every future access from every caller.
return "0.0.0";
}
}

private static Assembly? FindTrayAssembly()
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
if (string.Equals(asm.GetName().Name, TrayAssemblyName, StringComparison.Ordinal))
return asm;
}
return null;
}

private static string NormalizeSemVer(string s)
{
// Strip SourceLink build metadata, e.g. "0.4.7+abc123" -> "0.4.7".
var plus = s.IndexOf('+');
if (plus >= 0) s = s.Substring(0, plus);

// Strip the SemVer pre-release suffix, e.g. "0.4.7-beta.1" -> "0.4.7".
// This keeps the UI string aligned with Updatum, which compares the
// numeric AssemblyVersion only. Revisit if pre-release labels should
// ever be surfaced to users.
var dash = s.IndexOf('-');
if (dash >= 0) s = s.Substring(0, dash);

return s;
}
}

8 changes: 2 additions & 6 deletions src/OpenClaw.Shared/Capabilities/DeviceCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.IO;
using System.Linq;
using System.Net.NetworkInformation;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

Expand Down Expand Up @@ -53,10 +52,7 @@ private NodeInvokeResponse HandleInfo()
{
Logger.Info("device.info");

var assembly = typeof(DeviceCapability).Assembly;
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
var version = AppVersionInfo.Version;

return Success(new
{
Expand All @@ -65,7 +61,7 @@ private NodeInvokeResponse HandleInfo()
systemName = OperatingSystem.IsWindows() ? "Windows" : RuntimeInformation.OSDescription,
systemVersion = RuntimeInformation.OSDescription,
appVersion = version,
appBuild = assembly.GetName().Version?.ToString() ?? version,
appBuild = version,
locale = CultureInfo.CurrentCulture.Name
});
}
Expand Down
Loading
Loading