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
149 changes: 147 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<core>\d+(?:\.\d+){1,3})(?:-(?P<pre>[0-9A-Za-z][0-9A-Za-z.-]*))?"
)
pr_tag_pattern = re.compile(r"^pr-(?P<number>\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.<number>.<run> and
# base-manual.<run>. 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
17 changes: 12 additions & 5 deletions Docs/LinuxDeploymentAndUpdates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>` semver pre-release form so restore/publish do not fail. The
Expand All @@ -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<version>` → full release tagged `v<version>`
- push to `main` → full release tagged `v1.0.0-main.<run-number>`
- pull request → draft prerelease tagged `pr-<number>/v1.0.0-pr.<number>.<run-number>`
- manual run (`workflow_dispatch`) → draft prerelease tagged `v1.0.0-manual.<run-number>`
- push to `main` → full release tagged `v<base>.<build-number>`
- pull request → draft prerelease tagged `pr-<number>/v<base>.<run-number>-pr.<number>`
- manual run (`workflow_dispatch`) → draft prerelease tagged `v<base>.<run-number>-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

Expand Down
3 changes: 3 additions & 0 deletions Docs/QuasarArchitecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion Docs/QuasarPluginSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ImplicitLoading>true</ImplicitLoading>` for
reviewed plugins that should be present by default. Quasar installs or updates
Expand Down
2 changes: 1 addition & 1 deletion Docs/WindowsDeploymentAndUpdates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions Magnetar.Protocol/Runtime/QuasarReleaseVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public static class QuasarReleaseVersion
@"(?<core>\d+(?:\.\d+){1,3})(?:-(?<pre>[0-9A-Za-z][0-9A-Za-z.-]*))?",
RegexOptions.Compiled | RegexOptions.CultureInvariant);

private static readonly Regex LegacyChannelPrereleasePattern = new(
@"^(?:(?<channel>main|manual)\.(?<build>\d+)|pr\.(?<pr>\d+)\.(?<build>\d+))$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);

public static string GetEntryAssemblyVersion()
{
var assembly = Assembly.GetEntryAssembly();
Expand Down Expand Up @@ -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}";

Expand All @@ -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!;
Expand Down
5 changes: 1 addition & 4 deletions Quasar/Services/DedicatedServerSupervisor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading