diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d604205c..03019eab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: draft="false" prerelease="false" elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - version="${base}-manual.${GITHUB_RUN_NUMBER}" + version="${base}.${GITHUB_RUN_NUMBER}-manual" tag="v${version}" draft="true" prerelease="true" @@ -129,7 +129,7 @@ jobs: echo "Pull request number is not available." >&2 exit 1 fi - version="${base}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + version="${base}.${GITHUB_RUN_NUMBER}-pr.${PR_NUMBER}" tag="pr-${PR_NUMBER}/v${version}" draft="true" prerelease="true" @@ -537,3 +537,148 @@ jobs: release/quasar-installer-windows.zip release/quasar-web-win-x64.zip release/SHA256SUMS + + - name: Prune older releases + env: + CURRENT_TAG: ${{ needs.metadata.outputs.tag }} + GH_TOKEN: ${{ github.token }} + RELEASES_TO_KEEP: "2" + run: | + set -euo pipefail + + python3 <<'PY' + import json + import os + import re + import urllib.request + + api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + repo = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GH_TOKEN"] + current_tag = os.environ["CURRENT_TAG"] + releases_to_keep = int(os.environ.get("RELEASES_TO_KEEP", "2")) + + version_pattern = re.compile( + r"(?P\d+(?:\.\d+){1,3})(?:-(?P
[0-9A-Za-z][0-9A-Za-z.-]*))?"
+          )
+          pr_tag_pattern = re.compile(r"^pr-(?P\d+)/")
+
+          def next_page(link_header):
+              for part in link_header.split(","):
+                  pieces = [piece.strip() for piece in part.split(";")]
+                  if len(pieces) < 2:
+                      continue
+                  if any(piece == 'rel="next"' for piece in pieces[1:]):
+                      return pieces[0].strip("<>")
+              return None
+
+          def get_releases():
+              releases = []
+              url = f"{api_url}/repos/{repo}/releases?per_page=100"
+              headers = {
+                  "Accept": "application/vnd.github+json",
+                  "Authorization": f"Bearer {token}",
+                  "User-Agent": "Quasar release prune",
+                  "X-GitHub-Api-Version": "2022-11-28",
+              }
+              while url:
+                  request = urllib.request.Request(url, headers=headers)
+                  with urllib.request.urlopen(request) as response:
+                      releases.extend(json.load(response))
+                      url = next_page(response.headers.get("Link", ""))
+              return releases
+
+          def version_key(tag):
+              matches = list(version_pattern.finditer(tag or ""))
+              if not matches:
+                  return (0, 0, 0, 0)
+
+              match = matches[-1]
+              numbers = [int(part) for part in match.group("core").split(".")]
+              prerelease = match.group("pre") or ""
+              prerelease_parts = prerelease.split(".") if prerelease else []
+              prerelease_build = None
+
+              if prerelease.isdigit():
+                  prerelease_build = int(prerelease)
+              elif prerelease_parts:
+                  # Legacy PR/manual tags used base-pr.. and
+                  # base-manual.. Sort them by the trailing run number so
+                  # current review builds are not treated as older than stable
+                  # base releases only because they carry a non-numeric label.
+                  if prerelease_parts[0] in {"main", "manual"} and prerelease_parts[-1].isdigit():
+                      prerelease_build = int(prerelease_parts[-1])
+                  elif (
+                      prerelease_parts[0] == "pr"
+                      and len(prerelease_parts) >= 3
+                      and prerelease_parts[-1].isdigit()
+                  ):
+                      prerelease_build = int(prerelease_parts[-1])
+
+              while len(numbers) < 3:
+                  numbers.append(0)
+              if len(numbers) < 4:
+                  numbers.append(prerelease_build or 0)
+
+              return tuple(numbers[:4])
+
+          def release_time(release):
+              return release.get("published_at") or release.get("created_at") or ""
+
+          def managed_release(tag):
+              return tag.startswith("v") or pr_tag_pattern.match(tag) is not None
+
+          def release_stream(release):
+              tag = release["tag_name"]
+              if not release.get("draft") and not release.get("prerelease"):
+                  return "active"
+
+              pr_match = pr_tag_pattern.match(tag)
+              if pr_match:
+                  return f"pr-{pr_match.group('number')}"
+
+              if "manual" in tag:
+                  return "manual"
+
+              return "prerelease"
+
+          releases = [release for release in get_releases() if managed_release(release.get("tag_name", ""))]
+          keep_tags = {current_tag}
+          grouped = {}
+          for release in releases:
+              grouped.setdefault(release_stream(release), []).append(release)
+
+          for stream, stream_releases in grouped.items():
+              ordered = sorted(
+                  stream_releases,
+                  key=lambda release: (version_key(release["tag_name"]), release_time(release)),
+                  reverse=True,
+              )
+              retained = ordered[:releases_to_keep]
+              keep_tags.update(release["tag_name"] for release in retained)
+              print(f"{stream}: keeping {', '.join(release['tag_name'] for release in retained)}")
+
+          delete_tags = [
+              release["tag_name"]
+              for release in sorted(releases, key=lambda item: item.get("created_at") or "")
+              if release["tag_name"] not in keep_tags
+          ]
+
+          with open("releases-to-delete.txt", "w", encoding="utf-8") as output:
+              for tag in delete_tags:
+                  output.write(f"{tag}\n")
+
+          if delete_tags:
+              print(f"Pruning {len(delete_tags)} older release(s): {', '.join(delete_tags)}")
+          else:
+              print("No older releases to prune.")
+          PY
+
+          if [ ! -s releases-to-delete.txt ]; then
+            exit 0
+          fi
+
+          while IFS= read -r tag; do
+            echo "Deleting old release and tag: $tag"
+            gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes
+          done < releases-to-delete.txt
diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md
index 1e0b9420..06dfbcfd 100644
--- a/Docs/LinuxDeploymentAndUpdates.md
+++ b/Docs/LinuxDeploymentAndUpdates.md
@@ -32,7 +32,7 @@ by name, so all platforms share the same release.
 For assembly/file metadata, the script always emits a valid `major.minor.build`
 version even when the base version is build-number style. The public update
 identity is `AssemblyInformationalVersion`, which keeps prerelease labels such
-as `1.0.0-main.7`; Quasar uses that value, plus the active-release pointer, for
+as `1.0.0.123-pr.45`; Quasar uses that value, plus the active-release pointer, for
 update comparisons instead of `AssemblyVersion`.
 For NuGet/package metadata, non-tag/short-hash values are mapped to a safe
 `1.0.0-` semver pre-release form so restore/publish do not fail. The
@@ -55,13 +55,20 @@ The release workflow is `.github/workflows/release.yml`. Each build publishes a
 single release/tag carrying both the Linux and Windows archives:
 
 - tag push `v` → full release tagged `v`
-- push to `main` → full release tagged `v1.0.0-main.`
-- pull request → draft prerelease tagged `pr-/v1.0.0-pr..`
-- manual run (`workflow_dispatch`) → draft prerelease tagged `v1.0.0-manual.`
+- push to `main` → full release tagged `v.`
+- pull request → draft prerelease tagged `pr-/v.-pr.`
+- manual run (`workflow_dispatch`) → draft prerelease tagged `v.-manual`
 
 The updater extracts the version from the tag with
 `QuasarReleaseVersion.Normalize`, so the tag prefix does not matter. Assembly/file
-metadata is normalized to `major.minor.build`.
+metadata is normalized to `major.minor.build`. PR and manual prerelease tags keep
+their numeric run number before the channel label, so update checks and release
+retention compare them by numeric build first instead of treating `-pr` or
+`-manual` as older than the base release.
+
+After publishing, the workflow prunes older GitHub releases with their tags. It
+keeps the newest two full active releases, plus the newest two draft/prerelease
+review builds per PR/manual stream.
 
 ## First Start
 
diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md
index 0551c933..9d440fd6 100644
--- a/Docs/QuasarArchitecture.md
+++ b/Docs/QuasarArchitecture.md
@@ -523,6 +523,9 @@ Practical guarantee:
   first telemetry snapshot under startup grace before applying the normal
   heartbeat timeout
 - the supervisor must preserve enough state that reconnect is operationally seamless
+- when the replacement supervisor adopts a still-live server process by id, it
+  reports the process as running (except during startup grace) instead of
+  carrying stale stopping/restarting UI state from the old worker
 - managed DS processes continue running independently during the rollover
 - already-running DS processes keep their loaded `Quasar.Agent` assembly until
   that server process exits; after reconnect, the supervisor compares the
diff --git a/Docs/QuasarPluginSystem.md b/Docs/QuasarPluginSystem.md
index c1935923..d640c81e 100644
--- a/Docs/QuasarPluginSystem.md
+++ b/Docs/QuasarPluginSystem.md
@@ -221,7 +221,11 @@ Install, update, and remove operations change files on disk only. The active
 plugin assembly set is still loaded at Quasar worker startup, so each operation
 requires a Quasar restart before it takes effect. When that restart is triggered
 from the UI Plugins page, the browser shows restart progress and polls
-`/api/health` until the replacement worker is ready.
+`/api/health` until the replacement worker is ready. The restart watcher is
+stored in browser session storage, so refreshing the page resumes the same
+progress overlay and health polling. Managed Space Engineers server processes
+stay detached during the Quasar worker restart and are adopted as running by the
+replacement worker when their process id is still alive.
 
 QuasarHub descriptors can set `true` for
 reviewed plugins that should be present by default. Quasar installs or updates
diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md
index e895919f..be35d055 100644
--- a/Docs/WindowsDeploymentAndUpdates.md
+++ b/Docs/WindowsDeploymentAndUpdates.md
@@ -38,7 +38,7 @@ manual extract does not spill files into the current folder.
 Version normalization is identical to `scripts/package-linux-release.sh`: the same
 input yields the same `AssemblyVersion`/`FileVersion` (`major.minor.build`) and
 the same `AssemblyInformationalVersion` (which keeps prerelease labels such as
-`1.0.0-main.7` and drives update comparisons). The bundled `Quasar.Agent.dll`
+`1.0.0.123-pr.45` and drives update comparisons). The bundled `Quasar.Agent.dll`
 is not release-version stamped, because SHA-256 drift detection should only warn
 when the deployable agent bytes actually differ. After a worker update, the
 supervisor compares bundled-vs-deployed agent DLL hashes during reconnect
diff --git a/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs b/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs
index 0abff670..32050536 100644
--- a/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs
+++ b/Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs
@@ -11,6 +11,10 @@ public static class QuasarReleaseVersion
         @"(?\d+(?:\.\d+){1,3})(?:-(?
[0-9A-Za-z][0-9A-Za-z.-]*))?",
         RegexOptions.Compiled | RegexOptions.CultureInvariant);
 
+    private static readonly Regex LegacyChannelPrereleasePattern = new(
+        @"^(?:(?main|manual)\.(?\d+)|pr\.(?\d+)\.(?\d+))$",
+        RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
+
     public static string GetEntryAssemblyVersion()
     {
         var assembly = Assembly.GetEntryAssembly();
@@ -51,6 +55,9 @@ public static string Normalize(string value)
 
         var core = NormalizeCore(match.Groups["core"].Value);
         var prerelease = match.Groups["pre"].Success ? match.Groups["pre"].Value : string.Empty;
+        if (TryNormalizeLegacyChannelPrerelease(core, prerelease, out var channelVersion))
+            return channelVersion;
+
         if (int.TryParse(prerelease, out _))
             return $"{core}.{prerelease}";
 
@@ -66,6 +73,28 @@ private static string NormalizeCore(string core)
         return string.Join(".", parts);
     }
 
+    private static bool TryNormalizeLegacyChannelPrerelease(string core, string prerelease, out string version)
+    {
+        version = string.Empty;
+        if (string.IsNullOrWhiteSpace(prerelease))
+            return false;
+
+        var match = LegacyChannelPrereleasePattern.Match(prerelease);
+        if (!match.Success)
+            return false;
+
+        var build = match.Groups["build"].Value;
+        var normalizedCore = $"{core}.{build}";
+        if (match.Groups["pr"].Success)
+        {
+            version = $"{normalizedCore}-pr.{match.Groups["pr"].Value}";
+            return true;
+        }
+
+        version = $"{normalizedCore}-{match.Groups["channel"].Value.ToLowerInvariant()}";
+        return true;
+    }
+
     private static bool TryParse(string value, out ReleaseVersion version)
     {
         version = default!;
diff --git a/Quasar/Services/DedicatedServerSupervisor.cs b/Quasar/Services/DedicatedServerSupervisor.cs
index 24aef7d3..7cec8460 100644
--- a/Quasar/Services/DedicatedServerSupervisor.cs
+++ b/Quasar/Services/DedicatedServerSupervisor.cs
@@ -1683,10 +1683,7 @@ private void RestorePersistedRuntimeState()
                     // unhealthy and restart (kill) it.
                     state.AgentWatchSinceUtc = DateTimeOffset.UtcNow;
                     state.State = persistedState.State is DedicatedServerProcessState.Starting
-                        or DedicatedServerProcessState.Running
-                        or DedicatedServerProcessState.Restarting
-                        or DedicatedServerProcessState.Stopping
-                        ? persistedState.State
+                        ? DedicatedServerProcessState.Starting
                         : DedicatedServerProcessState.Running;
                     state.LastMessage = "Process adopted after Quasar worker turnover.";
                     state.StoppedAtUtc = null;
diff --git a/Quasar/wwwroot/quasar-configs.js b/Quasar/wwwroot/quasar-configs.js
index 881a80ca..f63ed1bb 100644
--- a/Quasar/wwwroot/quasar-configs.js
+++ b/Quasar/wwwroot/quasar-configs.js
@@ -210,9 +210,14 @@ window.quasarConfigs = window.quasarConfigs || {
     showRestartFeedback(options) {
         const opts = options || {};
         const read = (camelName, pascalName, fallback) => opts?.[camelName] ?? opts?.[pascalName] ?? fallback;
+        const readNumber = (camelName, pascalName, fallback) => {
+            const value = read(camelName, pascalName, fallback);
+            return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+        };
         const titleText = read("title", "Title", "Restarting Quasar");
         const messageText = read("message", "Message", "Waiting for the web worker to come back online.");
         const statusText = read("initialStatus", "InitialStatus", "Preparing restart request.");
+        const startedAt = readNumber("startedAt", "StartedAt", Date.now());
         const steps = read("steps", "Steps", [
             "Request restart",
             "Wait for worker stop",
@@ -271,7 +276,7 @@ window.quasarConfigs = window.quasarConfigs || {
             window.quasarRestartFeedback = state;
         }
 
-        state.startedAt = Date.now();
+        state.startedAt = startedAt;
         state.title.textContent = titleText;
         state.message.textContent = messageText;
         state.stepList.replaceChildren();
@@ -316,9 +321,72 @@ window.quasarConfigs = window.quasarConfigs || {
         if (state && state.root) {
             state.root.remove();
         }
+        window.quasarConfigs.clearRestartReloadState();
         window.quasarRestartFeedback = null;
         window.quasarRestartReloadSession = null;
     },
+    getRestartReloadStorageKey() {
+        return "quasar.restartReload";
+    },
+    readRestartReloadState() {
+        try {
+            const raw = window.sessionStorage?.getItem(window.quasarConfigs.getRestartReloadStorageKey());
+            if (!raw) {
+                return null;
+            }
+
+            return JSON.parse(raw);
+        } catch {
+            window.quasarConfigs.clearRestartReloadState();
+            return null;
+        }
+    },
+    writeRestartReloadState(state) {
+        try {
+            window.sessionStorage?.setItem(
+                window.quasarConfigs.getRestartReloadStorageKey(),
+                JSON.stringify(state));
+        } catch {
+            // sessionStorage can be unavailable in private or locked-down browsers.
+        }
+    },
+    clearRestartReloadState() {
+        try {
+            window.sessionStorage?.removeItem(window.quasarConfigs.getRestartReloadStorageKey());
+        } catch {
+            // Ignore storage failures; in-memory restart polling still works.
+        }
+    },
+    resumeRestartReload() {
+        const saved = window.quasarConfigs.readRestartReloadState();
+        if (!saved || !saved.url) {
+            return false;
+        }
+
+        const options = saved.options || {};
+        const read = (camelName, pascalName, fallback) => options?.[camelName] ?? options?.[pascalName] ?? fallback;
+        const readNumber = (camelName, pascalName, fallback) => {
+            const value = read(camelName, pascalName, fallback);
+            return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+        };
+        const startedAt = typeof saved.startedAt === "number" && Number.isFinite(saved.startedAt)
+            ? saved.startedAt
+            : readNumber("startedAt", "StartedAt", Date.now());
+        const maxWaitMs = readNumber("maxWaitMs", "MaxWaitMs", 120000);
+        if (Date.now() - startedAt >= maxWaitMs + 1000) {
+            window.quasarConfigs.clearRestartReloadState();
+            return false;
+        }
+
+        window.quasarConfigs.reloadWhenHealthy(saved.url, {
+            ...options,
+            initialDelayMs: 0,
+            startedAt,
+            resumeSessionId: saved.sessionId || read("resumeSessionId", "ResumeSessionId", ""),
+            observedUnhealthy: !!(saved.observedUnhealthy ?? read("observedUnhealthy", "ObservedUnhealthy", false))
+        });
+        return true;
+    },
     // Used when the Quasar worker is being restarted: the Blazor circuit drops, so we
     // poll the (anonymous) health endpoint from the browser and navigate to the target
     // page once the new worker answers. Falls back to a plain reload after a timeout.
@@ -326,9 +394,14 @@ window.quasarConfigs = window.quasarConfigs || {
         const url = targetUrl || "/";
         const opts = options || {};
         const read = (camelName, pascalName, fallback) => opts?.[camelName] ?? opts?.[pascalName] ?? fallback;
-        const pollIntervalMs = opts.pollIntervalMs || 1000;
-        const maxWaitMs = opts.maxWaitMs || 120000;
-        const initialDelayMs = opts.initialDelayMs || 1500;
+        const readNumber = (camelName, pascalName, fallback) => {
+            const value = read(camelName, pascalName, fallback);
+            return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+        };
+        const pollIntervalMs = readNumber("pollIntervalMs", "PollIntervalMs", 1000);
+        const maxWaitMs = readNumber("maxWaitMs", "MaxWaitMs", 120000);
+        const initialDelayMs = readNumber("initialDelayMs", "InitialDelayMs", 1500);
+        const stopWaitFallbackMs = readNumber("stopWaitFallbackMs", "StopWaitFallbackMs", 10000);
         const expectedVersion = (opts.expectedVersion || opts.ExpectedVersion || "").toString().trim().toLowerCase();
         const requireUnhealthy = !!(opts.requireUnhealthy ?? opts.RequireUnhealthy);
         const showFeedback = !!read(
@@ -351,12 +424,49 @@ window.quasarConfigs = window.quasarConfigs || {
             "timeoutMessage",
             "TimeoutMessage",
             "Still waiting for Quasar. Reloading page now.");
-        const startedAt = Date.now();
-        const sessionId = `${startedAt}:${Math.random()}`;
-        let observedUnhealthy = !requireUnhealthy;
+        const startedAt = readNumber("startedAt", "StartedAt", Date.now());
+        const sessionId = (read("resumeSessionId", "ResumeSessionId", "") || `${startedAt}:${Math.random()}`).toString();
+        let observedUnhealthy = !!read("observedUnhealthy", "ObservedUnhealthy", !requireUnhealthy);
         window.quasarRestartReloadSession = sessionId;
 
         const isCurrentSession = () => window.quasarRestartReloadSession === sessionId;
+        const persistState = () => {
+            window.quasarConfigs.writeRestartReloadState({
+                url,
+                sessionId,
+                startedAt,
+                observedUnhealthy,
+                options: {
+                    ...opts,
+                    initialDelayMs: 0,
+                    startedAt,
+                    resumeSessionId: sessionId,
+                    observedUnhealthy,
+                    stopWaitFallbackMs
+                }
+            });
+        };
+        const markObservedUnhealthy = () => {
+            if (!observedUnhealthy) {
+                observedUnhealthy = true;
+                persistState();
+            }
+        };
+        const canAcceptHealthyWorker = () => {
+            if (observedUnhealthy || !requireUnhealthy) {
+                return true;
+            }
+
+            if (Date.now() - startedAt < stopWaitFallbackMs) {
+                return false;
+            }
+
+            // Browser may miss the brief outage when the worker restarts quickly.
+            markObservedUnhealthy();
+            return true;
+        };
+
+        persistState();
 
         if (showFeedback) {
             window.quasarConfigs.showRestartFeedback(opts);
@@ -377,6 +487,7 @@ window.quasarConfigs = window.quasarConfigs || {
                 updateFeedback("timeout", timeoutMessage);
                 window.setTimeout(() => {
                     if (isCurrentSession()) {
+                        window.quasarConfigs.clearRestartReloadState();
                         window.location.href = url;
                     }
                 }, 500);
@@ -399,7 +510,8 @@ window.quasarConfigs = window.quasarConfigs || {
                 return;
             }
 
-            updateFeedback(observedUnhealthy ? "health" : "stop", observedUnhealthy ? pollingMessage : waitingForStopMessage);
+            const currentPhase = canAcceptHealthyWorker() ? "health" : "stop";
+            updateFeedback(currentPhase, currentPhase === "health" ? pollingMessage : waitingForStopMessage);
             fetch("/api/health", { cache: "no-store" })
                 .then(async (response) => {
                     if (!isCurrentSession()) {
@@ -407,7 +519,7 @@ window.quasarConfigs = window.quasarConfigs || {
                     }
 
                     if (!response.ok) {
-                        observedUnhealthy = true;
+                        markObservedUnhealthy();
                         updateFeedback("health", pollingMessage);
                         scheduleNext();
                         return;
@@ -426,6 +538,7 @@ window.quasarConfigs = window.quasarConfigs || {
                         updateFeedback("reload", successMessage);
                         window.setTimeout(() => {
                             if (isCurrentSession()) {
+                                window.quasarConfigs.clearRestartReloadState();
                                 window.location.href = url;
                             }
                         }, 250);
@@ -439,7 +552,7 @@ window.quasarConfigs = window.quasarConfigs || {
                         return;
                     }
 
-                    observedUnhealthy = true;
+                    markObservedUnhealthy();
                     updateFeedback("health", pollingMessage);
                     scheduleNext();
                 });
@@ -471,3 +584,5 @@ window.quasarConfigs = window.quasarConfigs || {
         }
     }
 };
+
+window.setTimeout(() => window.quasarConfigs.resumeRestartReload?.(), 0);
diff --git a/scripts/package-linux-release.sh b/scripts/package-linux-release.sh
index 8451be2a..cd5a0517 100755
--- a/scripts/package-linux-release.sh
+++ b/scripts/package-linux-release.sh
@@ -8,6 +8,7 @@ CONFIGURATION="${CONFIGURATION:-Release}"
 RUNTIME="${RUNTIME:-linux-x64}"
 VERSION="${VERSION:-}"
 ASSEMBLY_FILE_VERSION="1.0.0"
+INFORMATIONAL_VERSION="$VERSION"
 NUGET_VERSION="$VERSION"
 WEB_ARCHIVE_NAME="quasar-web-linux-x64.tar.gz"
 INSTALLER_ARCHIVE_NAME="quasar-installer-linux.tar.gz"
@@ -39,6 +40,11 @@ normalize_nuget_version() {
         return
     fi
 
+    if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)-([0-9A-Za-z][0-9A-Za-z.-]*)$ ]]; then
+        echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}-${BASH_REMATCH[4]}.${BASH_REMATCH[5]}"
+        return
+    fi
+
     if [[ "$version" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then
         IFS='.' read -r -a version_parts <<< "$version"
         echo "${version_parts[0]}.${version_parts[1]}.${version_parts[2]}-${version_parts[3]}"
@@ -119,6 +125,7 @@ if [[ -z "$VERSION" ]]; then
     VERSION="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
 fi
 VERSION="${VERSION#v}"
+INFORMATIONAL_VERSION="$VERSION"
 NUGET_VERSION="$(normalize_nuget_version "$VERSION")"
 ASSEMBLY_FILE_VERSION="$(build_assembly_file_version "$VERSION")"
 
@@ -143,7 +150,7 @@ dotnet publish "$REPO_DIR/Quasar.Bootstrap/Quasar.Bootstrap.csproj" \
     -p:Version="$NUGET_VERSION" \
     -p:AssemblyVersion="$ASSEMBLY_FILE_VERSION" \
     -p:FileVersion="$ASSEMBLY_FILE_VERSION" \
-    -p:InformationalVersion="$NUGET_VERSION" \
+    -p:InformationalVersion="$INFORMATIONAL_VERSION" \
     -o "$PUBLISH_DIR" \
     -v minimal
 
diff --git a/scripts/package-windows-release.ps1 b/scripts/package-windows-release.ps1
index 32f64c1f..4e6f65ff 100644
--- a/scripts/package-windows-release.ps1
+++ b/scripts/package-windows-release.ps1
@@ -45,6 +45,10 @@ function Normalize-NugetVersion {
 
     if ($v -match '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$') { return $v }
 
+    if ($v -match '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)-([0-9A-Za-z][0-9A-Za-z.-]*)$') {
+        return "$($Matches[1]).$($Matches[2]).$($Matches[3])-$($Matches[4]).$($Matches[5])"
+    }
+
     if ($v -match '^[0-9]+(\.[0-9]+){3}$') {
         $parts = $v.Split('.')
         return "$($parts[0]).$($parts[1]).$($parts[2])-$($parts[3])"
@@ -185,6 +189,7 @@ if ([string]::IsNullOrWhiteSpace($Version)) {
     $Version = (Invoke-GitProbe -C $RepoDir rev-parse --short HEAD).Output
 }
 $Version = $Version -replace '^v', ''
+$InformationalVersion = $Version
 $NugetVersion = Normalize-NugetVersion $Version
 $AssemblyFileVersion = Build-AssemblyFileVersion $Version
 
@@ -216,7 +221,7 @@ Write-Host "Publishing Quasar.Bootstrap ($Configuration, $Runtime, version $Nuge
     -p:Version=$NugetVersion `
     -p:AssemblyVersion=$AssemblyFileVersion `
     -p:FileVersion=$AssemblyFileVersion `
-    -p:InformationalVersion=$NugetVersion `
+    -p:InformationalVersion=$InformationalVersion `
     -o $PublishDir `
     -v minimal
 if ($LASTEXITCODE -ne 0) { throw "dotnet publish Quasar.Bootstrap failed with exit code $LASTEXITCODE" }