Skip to content

Replay archived games from older builds via versioned CDN shells - #4959

Merged
evanpelle merged 5 commits into
mainfrom
feat/replay-older-versions
Aug 11, 2026
Merged

Replay archived games from older builds via versioned CDN shells#4959
evanpelle merged 5 commits into
mainfrom
feat/replay-older-versions

Conversation

@evanpelle

@evanpelle evanpelle commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4934

Archived games can only be replayed by the exact build that produced them - the client refuses mismatched gitCommits because the deterministic sim would desync. Once a deployment is replaced, its games become unwatchable. This PR keeps every build replayable by publishing a static, self-contained copy of the app shell per deploy and redirecting mismatched replays to it.

Upload side (update.sh + RenderStaticIndex.ts)

After the existing asset upload, the deploy renders the app shell inside the freshly built image (docker run --entrypoint npx <image> tsx src/server/RenderStaticIndex.ts, a thin wrapper around the server's own renderHtmlContent) using the same env file the live container gets, so the baked BOOTSTRAP_CONFIG (gitCommit, cdnBase, jwtAudience, ...) matches what the server would have served. The result is PUT to R2 as index-<short-commit>.html next to the hashed assets it references. Since hashed assets are immutable and never deleted, the shell + CDN + API are enough to replay - no game server involved.

Redirect side

The canonical replay URL is https://replay.<domain>/<gameId> (openfrontio/infra#514): the API worker reads the archived record's gitCommit and serves the matching shell at that URL, so the address players see and share carries only the game id. The client derives the host from the JWT audience, exactly like getApiBase() derives api.<audience> - no new config.

  • JoinLobbyModal.checkArchivedGame: on commit mismatch, HEAD-probe replay.<audience>/<gameId> and navigate there. The probe requires 200 and text/html; games from builds that predate this feature 404 (no shell uploaded) and fall back to the existing version-mismatch message.
  • Main.ts: on a replay shell host the pathname IS the game id - the shell parses it and starts the join flow. The active-lobby check fails harmlessly (relative URL), the archive fetch succeeds (replay.<aud> is covered by the API's subdomain CORS rule), the commit matches, and the replay runs locally.
  • The canonical URL is preserved through join and game start (reload/share keeps working); non-shell pages keep the existing /game/<id> URL behavior.
  • VersionedReplay.ts: pure URL helpers, unit-tested. Loop guard: pages on a replay.* host never redirect again, so a bad record cannot loop.
  • Replays no longer request a Turnstile token (lobby.gameRecord set -> null, mirroring the singleplayer exemption - replays run through LocalServer, nothing consumes a token), and shell hosts skip the prefetch entirely: the replay host may not be on the Turnstile site key's allowlist, where the widget would alert and reject.

Deploy order

Requires openfrontio/infra#514 (serve shells on replay.<domain>; supersedes the serving half of infra#513, whose special grants it reverts). Safe to merge in either order: until infra deploys, the client probe fails and behavior is unchanged (version-mismatch message).

Testing

  • tests/VersionedReplay.test.ts covers URL building (audience-derived host, localhost -> null) and the host-based loop-guard invariant.
  • Renderer verified end-to-end locally: prod build, then RenderStaticIndex.ts with staging-like env produced a self-contained shell with the full commit, cdnBase, and absolute CDN asset URLs baked in.
  • Full redirect + replay loop e2e-verified on staging (with the interim CDN-host URLs): game mcKawtSy archived at one commit, opened on a deployment of another -> redirect -> shell replayed it (sim ticking, map rendered, URL preserved). Re-verification with the canonical URLs planned after infra#514 deploys.
  • Full suite, tsc, and lint pass.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Archived games can use commit-specific replay shells. The client detects replay-shell URLs, redirects archived games when a matching CDN shell exists, and skips Turnstile for replay flows. Deployment renders, validates, and uploads versioned shells.

Changes

Versioned replay support

Layer / File(s) Summary
Versioned replay URL contract
src/client/VersionedReplay.ts, tests/VersionedReplay.test.ts
Adds replay URL generation and replay-shell host detection. Tests cover audience handling and host matching.
Archived replay navigation
src/client/JoinLobbyModal.ts, src/client/Main.ts
Archived-game checks probe matching HTML shells and preserve lobby state during redirects. Replay-shell paths open the join modal, preserve replay paths, redirect correctly when leaving, and bypass Turnstile operations.
Versioned shell deployment
src/server/RenderStaticIndex.ts, update.sh
Renders a commit-specific static shell, validates BOOTSTRAP_CONFIG, and uploads the shell to R2 with retries.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JoinLobbyModal
  participant CDN
  participant Browser
  participant Main
  JoinLobbyModal->>JoinLobbyModal: Check archived game commit
  JoinLobbyModal->>CDN: Probe versioned replay shell
  CDN-->>JoinLobbyModal: Return successful text/html response
  JoinLobbyModal->>Browser: Navigate to versioned shell
  Browser->>Main: Load replay-shell game URL
  Main->>JoinLobbyModal: Open join modal
Loading

Possibly related PRs

Suggested reviewers: ryanbarlow97, celant, variablevince

Poem

Old games find their matching shell,
Commit paths identify it well.
R2 stores the rendered page,
Join links open the replay stage.
Turnstile waits outside.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: replaying archived games through versioned CDN shells.
Description check ✅ Passed The description directly explains the archived replay problem and the versioned shell solution implemented by the changeset.
Linked Issues check ✅ Passed The changes implement the linked objective to replay archived games from older builds through versioned shells [#4934].
Out of Scope Changes check ✅ Passed The deployment, routing, redirect, Turnstile, helper, and test changes all support versioned replay of archived games.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/JoinLobbyModal.ts`:
- Around line 1196-1207: Update the CDN probe in checkArchivedGame to use an
AbortController with a short timeout, pass its signal to the HEAD fetch, and
clean up the timer afterward. Treat timeout-triggered aborts like other probe
failures by returning false so the existing version-mismatch flow continues.

In `@src/client/Main.ts`:
- Around line 823-832: Update the hash-based join flow around the joinMatch
handling and its downstream handleJoinLobby/game-start URL updates so versioned
replay pages detected by isVersionedReplayPage(window.location.pathname)
preserve the existing versioned shell pathname and `#join`=<gameId> hash instead
of replacing the URL with /game/<gameId>; retain the current /game/<gameId>
behavior for non-versioned pages.

In `@src/server/RenderStaticIndex.ts`:
- Around line 12-17: Update the error handling callback in RenderStaticIndex to
pass the user-visible “Failed to render static index:” message through the
repository’s translateText() helper, adding the required import and preserving
the existing error output and exit behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bfad9503-5b16-4c0b-999d-43adde56a969

📥 Commits

Reviewing files that changed from the base of the PR and between 0c0529e and 73a06bc.

📒 Files selected for processing (6)
  • src/client/JoinLobbyModal.ts
  • src/client/Main.ts
  • src/client/VersionedReplay.ts
  • src/server/RenderStaticIndex.ts
  • tests/VersionedReplay.test.ts
  • update.sh

Comment on lines +1196 to +1207
try {
const probe = await fetch(url.split("#")[0], { method: "HEAD" });
if (!probe.ok) {
return false;
}
const contentType = probe.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return false;
}
} catch {
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the CDN shell probe.

fetch() has no default timeout. If the CDN connection stalls, checkArchivedGame() never returns and the join modal stays in its connecting state.

Abort the HEAD request after a short timeout. Return false after the abort so the existing version-mismatch flow can continue.

Proposed fix
+    const controller = new AbortController();
+    const timeoutId = window.setTimeout(() => controller.abort(), 5000);
     try {
-      const probe = await fetch(url.split("#")[0], { method: "HEAD" });
+      const probe = await fetch(url.split("#")[0], {
+        method: "HEAD",
+        signal: controller.signal,
+      });
       if (!probe.ok) {
         return false;
       }
       const contentType = probe.headers.get("content-type") ?? "";
       if (!contentType.includes("text/html")) {
         return false;
       }
     } catch {
       return false;
+    } finally {
+      clearTimeout(timeoutId);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const probe = await fetch(url.split("#")[0], { method: "HEAD" });
if (!probe.ok) {
return false;
}
const contentType = probe.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return false;
}
} catch {
return false;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 5000);
try {
const probe = await fetch(url.split("#")[0], {
method: "HEAD",
signal: controller.signal,
});
if (!probe.ok) {
return false;
}
const contentType = probe.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return false;
}
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/JoinLobbyModal.ts` around lines 1196 - 1207, Update the CDN probe
in checkArchivedGame to use an AbortController with a short timeout, pass its
signal to the HEAD fetch, and clean up the timer afterward. Treat
timeout-triggered aborts like other probe failures by returning false so the
existing version-mismatch flow continues.

Comment thread src/client/Main.ts Outdated
Comment on lines +12 to +17
renderHtmlContent(path.join(__dirname, "../../static/index.html")).then(
(html) => process.stdout.write(html),
(error: unknown) => {
console.error("Failed to render static index:", error);
process.exit(1);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use translateText() for the error message.

Line 15 shows operator-visible text without translateText(). Import and use the repository translation helper for "Failed to render static index:".

As per coding guidelines: **/*.{ts,tsx}: All user-visible text must go through translateText().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/RenderStaticIndex.ts` around lines 12 - 17, Update the error
handling callback in RenderStaticIndex to pass the user-visible “Failed to
render static index:” message through the repository’s translateText() helper,
adding the required import and preserving the existing error output and exit
behavior.

Source: Coding guidelines

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Aug 11, 2026
@evanpelle evanpelle added this to the v34 milestone Aug 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/Main.ts`:
- Around line 1077-1090: Update the history-replacement comparison following the
targetUrl selection to compare the complete relative URL, including the hash,
rather than only window.location.pathname. Ensure the versioned replay case in
isVersionedReplayPage removes an existing `#join` fragment when lobbyIdHidden is
true, while preserving unchanged URLs and the existing behavior for other
targetUrl branches.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 83edb46b-1515-46b1-8a5f-ba66103059d5

📥 Commits

Reviewing files that changed from the base of the PR and between 73a06bc and f579ae4.

📒 Files selected for processing (1)
  • src/client/Main.ts

Comment thread src/client/Main.ts
evanpelle and others added 2 commits August 11, 2026 13:28
Every deploy now uploads a fully-rendered app shell to R2 as
index-<short-commit>.html, rendered inside the image with the deploy's own
env so the baked BOOTSTRAP_CONFIG matches what the server would serve. When
a replay's record was produced by a different build, the client probes the
CDN for the matching shell and navigates to it (#join=<id> hash, since
static hosting has no path routing); the shell's build then simulates the
game under the rules it was played with.

Replays no longer request a Turnstile token (they simulate locally from the
record), and the shells skip the prefetch entirely - the CDN origin is not
on the site key's allowlist.

Fixes #4934

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a versioned replay shell, updateJoinUrlForShare and the prestart handler
rewrote the address to /game/<id> (and the #refresh trampoline could rewrite
it to the bare origin) - URLs that only exist on the game-server origin and
404 on the CDN when reloaded or shared. Keep the shell pathname and #join=
hash there; non-shell pages keep the existing behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The versioned shells are now served on replay.openfront.io/.dev by the
API worker (infra#514), so the redirect derives the host from the JWT
audience - exactly like getApiBase() derives api.<audience> - instead of
building a cdn.ofedge.* URL from cdnBase. No client config needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/VersionedReplay.ts`:
- Around line 33-34: Update isVersionedReplayPage to match only the host-root
shell path /index-<SHORT_COMMIT_LENGTH>-hex.html, reusing the existing
SHORT_COMMIT_LENGTH constant rather than allowing arbitrary nesting or hash
lengths. Add negative tests covering nested paths and hashes with incorrect
lengths while preserving valid root-path matches.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ff7b9aa1-b50d-4bcd-ae5f-32631fb2c3fc

📥 Commits

Reviewing files that changed from the base of the PR and between f579ae4 and 611307b.

📒 Files selected for processing (3)
  • src/client/JoinLobbyModal.ts
  • src/client/VersionedReplay.ts
  • tests/VersionedReplay.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/JoinLobbyModal.ts

Comment thread src/client/VersionedReplay.ts Outdated
Comment on lines +33 to +34
export function isVersionedReplayPage(pathname: string): boolean {
return /\/index-[0-9a-f]+\.html$/.test(pathname);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match the exact generated shell path.

Line 34 accepts nested paths and any hexadecimal length. The deployment generates only /index-<7-hex>.html at the host root. A path such as /nested/index-0847945.html can therefore be treated as a versioned shell, causing redirectToVersionedShell() to skip the redirect. Match the root path and SHORT_COMMIT_LENGTH, then add negative tests for nested and wrong-length paths.

Proposed fix
 export function isVersionedReplayPage(pathname: string): boolean {
-  return /\/index-[0-9a-f]+\.html$/.test(pathname);
+  return new RegExp(
+    `^/index-[0-9a-f]{${SHORT_COMMIT_LENGTH}}\\.html$`,
+  ).test(pathname);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function isVersionedReplayPage(pathname: string): boolean {
return /\/index-[0-9a-f]+\.html$/.test(pathname);
export function isVersionedReplayPage(pathname: string): boolean {
return new RegExp(
`^/index-[0-9a-f]{${SHORT_COMMIT_LENGTH}}\\.html$`,
).test(pathname);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/VersionedReplay.ts` around lines 33 - 34, Update
isVersionedReplayPage to match only the host-root shell path
/index-<SHORT_COMMIT_LENGTH>-hex.html, reusing the existing SHORT_COMMIT_LENGTH
constant rather than allowing arbitrary nesting or hash lengths. Add negative
tests covering nested paths and hashes with incorrect lengths while preserving
valid root-path matches.

The replay worker now resolves the record's commit itself and serves the
matching shell at replay.<domain>/<gameId> (infra#514), so the client
redirect carries only the game id - no commit hash in the URL. On the
shell the pathname IS the game id, replacing the #join= hash mechanism,
and shell detection is host-based (replay.*) instead of pathname-based.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/client/VersionedReplay.ts (1)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a type-only import for GameID.

GameID is used only as a type. Use import type to make this dependency explicit and prevent a runtime import if import preservation is enabled later.

Suggested change
-import { GameID } from "../core/Schemas";
+import type { GameID } from "../core/Schemas";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/VersionedReplay.ts` at line 10, Change the GameID import in
VersionedReplay.ts to a type-only import, preserving its existing type usage and
avoiding a runtime dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/client/VersionedReplay.ts`:
- Line 10: Change the GameID import in VersionedReplay.ts to a type-only import,
preserving its existing type usage and avoiding a runtime dependency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 17b7a90b-94a3-471f-823e-66e38fa9e4d1

📥 Commits

Reviewing files that changed from the base of the PR and between 611307b and 0b3c9f6.

📒 Files selected for processing (4)
  • src/client/JoinLobbyModal.ts
  • src/client/Main.ts
  • src/client/VersionedReplay.ts
  • tests/VersionedReplay.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/VersionedReplay.test.ts
  • src/client/JoinLobbyModal.ts
  • src/client/Main.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/Main.ts (1)

1048-1054: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Protect replay rollback from a pending hashchange.

If a replay URL contains a hash, this pathname-only history.pushState creates the replay entry without that hash. When the player presses Back, onPopState restores this.currentUrl before the browser dispatches hashchange. onHashUpdate can then call onJoinChanged. The replay leave path can redirect to the main site even when the player cancels the exit confirmation.

Set a preventHashUpdate flag only when the traversed hash differs from the restored URL. Clear the flag at the start of onHashUpdate. Add a regression test for browser Back navigation followed by cancel on a hash-bearing replay URL.

Based on learnings: when popstate fires before hashchange during rollback, use a preventHashUpdate-like flag and document its set/reset conditions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Main.ts` around lines 1048 - 1054, Protect replay rollback from a
pending hashchange by updating the relevant onPopState/onHashUpdate flow: set
preventHashUpdate only when the traversed hash differs from the restored
currentUrl, clear it at the start of onHashUpdate, and document these set/reset
conditions. Add a regression test covering Back navigation on a hash-bearing
replay URL followed by cancelling the exit confirmation.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/client/Main.ts`:
- Around line 1048-1054: Protect replay rollback from a pending hashchange by
updating the relevant onPopState/onHashUpdate flow: set preventHashUpdate only
when the traversed hash differs from the restored currentUrl, clear it at the
start of onHashUpdate, and document these set/reset conditions. Add a regression
test covering Back navigation on a hash-bearing replay URL followed by
cancelling the exit confirmation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aec5b835-4c3e-49c1-8865-3c36a53d74c7

📥 Commits

Reviewing files that changed from the base of the PR and between 0b3c9f6 and 8ff19d3.

📒 Files selected for processing (1)
  • src/client/Main.ts

@evanpelle
evanpelle force-pushed the feat/replay-older-versions branch from 8ff19d3 to 58ed56c Compare August 11, 2026 22:02
@evanpelle
evanpelle merged commit 228143e into main Aug 11, 2026
21 checks passed
@evanpelle
evanpelle deleted the feat/replay-older-versions branch August 11, 2026 22:31
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

Allow replay of older versions

1 participant