summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 19:46:00 +0900
committerSho Sakuma <me@m1sk9.dev>2026-08-05 19:46:00 +0900
commitaf776eac4fd971483397f40e8c8452df485892e0 (patch)
tree3100e0da58e750f3c3e26f8293b169192f57059a
parentce1a6123f7d48100ba3b216746127ba269fe21fb (diff)
downloadLunaticChat-af776eac4fd971483397f40e8c8452df485892e0.tar.gz
LunaticChat-af776eac4fd971483397f40e8c8452df485892e0.tar.bz2
LunaticChat-af776eac4fd971483397f40e8c8452df485892e0.zip
fix: read plugin versions from release JAR names, not tag names
The download cards and the compatibility matrix derived each plugin's version from the release tag, which cannot hold for a unified `vX.Y.Z` release: it carries both JARs and their versions need not agree. v1.3.0 shipped Paper 1.3.0 alongside Velocity 1.2.0. So the download page advertised Paper v1.2.2 and Velocity v1.1.0, two generations stale, because it searched `paper/v` and `velocity/v` tags first and fell back to a unified tag only when none existed. The matrix meanwhile put a Velocity v1.3.0 on its axis that was never released, leaving no row for the proxy build operators actually run. Both now pick releases by the JARs attached to them and take the version from the file name, the only place it is stated. Tag shape stops mattering: a platform-specific tag and a unified one are alike just releases that happen to carry a given JAR. Releases rank by publication time rather than by API response order, which follows tag creation instead: velocity/v1.1.0 precedes the later-published paper/v1.2.2. A version is listed once even when a later unified release re-attaches an unchanged JAR, which release.yaml does whenever only the other platform was bumped. Otherwise the matrix would carry one version twice, under two protocol versions if the protocol had moved in between. Tests run in CI from here on. They need an explicit path because Bun does not discover tests inside dot-directories. Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--.github/workflows/ci.yaml3
-rw-r--r--website/.vitepress/theme/components/DownloadCard.vue14
-rw-r--r--website/.vitepress/theme/components/releaseAssets.test.ts144
-rw-r--r--website/.vitepress/theme/components/releaseAssets.ts78
-rw-r--r--website/.vitepress/theme/components/useCompatibilityData.ts60
-rw-r--r--website/.vitepress/theme/components/useDownloadData.ts70
-rw-r--r--website/package.json1
7 files changed, 281 insertions, 89 deletions
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 28e2dfb..16e4b50 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -118,6 +118,9 @@ jobs:
- name: Check lints
run: bun run lint
+ - name: Run tests
+ run: bun run test
+
- name: Build documentation
run: bun run build
diff --git a/website/.vitepress/theme/components/DownloadCard.vue b/website/.vitepress/theme/components/DownloadCard.vue
index add8d45..046de21 100644
--- a/website/.vitepress/theme/components/DownloadCard.vue
+++ b/website/.vitepress/theme/components/DownloadCard.vue
@@ -71,14 +71,12 @@ const t = computed(() =>
},
);
-function formatSize(bytes: number | null): string {
- if (!bytes) return '-';
+function formatSize(bytes: number): string {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
}
-function formatDate(dateStr: string | null): string {
- if (!dateStr) return '-';
+function formatDate(dateStr: string): string {
const locale = isEn.value ? 'en-US' : 'ja-JP';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
@@ -138,11 +136,11 @@ function formatDate(dateStr: string | null): string {
<dd>{{ formatSize(data.paper.fileSize) }}</dd>
</div>
<div>
- <dd><code>{{ data.paper.fileName ?? '-' }}</code></dd>
+ <dd><code>{{ data.paper.fileName }}</code></dd>
</div>
</dl>
<div class="download-actions">
- <a v-if="data.paper.downloadUrl" :href="data.paper.downloadUrl" class="download-btn primary">{{ t.download }}</a>
+ <a :href="data.paper.downloadUrl" class="download-btn primary">{{ t.download }}</a>
<a :href="data.paper.releaseUrl" class="download-btn" target="_blank" rel="noopener">{{ t.releaseNotes }}</a>
</div>
</div>
@@ -172,11 +170,11 @@ function formatDate(dateStr: string | null): string {
<dd>{{ formatSize(data.velocity.fileSize) }}</dd>
</div>
<div>
- <dd><code>{{ data.velocity.fileName ?? '-' }}</code></dd>
+ <dd><code>{{ data.velocity.fileName }}</code></dd>
</div>
</dl>
<div class="download-actions">
- <a v-if="data.velocity.downloadUrl" :href="data.velocity.downloadUrl" class="download-btn primary">{{ t.download }}</a>
+ <a :href="data.velocity.downloadUrl" class="download-btn primary">{{ t.download }}</a>
<a :href="data.velocity.releaseUrl" class="download-btn" target="_blank" rel="noopener">{{ t.releaseNotes }}</a>
</div>
</div>
diff --git a/website/.vitepress/theme/components/releaseAssets.test.ts b/website/.vitepress/theme/components/releaseAssets.test.ts
new file mode 100644
index 0000000..32dea7b
--- /dev/null
+++ b/website/.vitepress/theme/components/releaseAssets.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ findPlatformAsset,
+ type GitHubRelease,
+ latestPlatformRelease,
+ platformReleases,
+} from './releaseAssets';
+
+function release(
+ tag: string,
+ publishedAt: string,
+ assetNames: string[],
+): GitHubRelease {
+ return {
+ tag_name: tag,
+ published_at: publishedAt,
+ html_url: `https://github.com/m1sk9/LunaticChat/releases/tag/${tag}`,
+ assets: assetNames.map((name) => ({
+ name,
+ size: 1024,
+ browser_download_url: `https://example.invalid/${name}`,
+ })),
+ };
+}
+
+const UNIFIED_1_3_0 = release('v1.3.0', '2026-08-04T20:05:51Z', [
+ 'LunaticChat-1.2.0-velocity.jar',
+ 'LunaticChat-1.3.0.jar',
+]);
+const PAPER_1_2_2 = release('paper/v1.2.2', '2026-06-12T07:57:55Z', [
+ 'LunaticChat-1.2.2.jar',
+]);
+const VELOCITY_1_1_0 = release('velocity/v1.1.0', '2026-06-12T07:57:33Z', [
+ 'LunaticChat-1.1.0-velocity.jar',
+]);
+
+describe('findPlatformAsset', () => {
+ test('reads each platform version from its own JAR, not from the shared tag', () => {
+ expect(findPlatformAsset(UNIFIED_1_3_0, 'paper')?.version).toBe('1.3.0');
+ expect(findPlatformAsset(UNIFIED_1_3_0, 'velocity')?.version).toBe('1.2.0');
+ });
+
+ test('returns the asset backing the reported version', () => {
+ expect(findPlatformAsset(UNIFIED_1_3_0, 'velocity')?.asset.name).toBe(
+ 'LunaticChat-1.2.0-velocity.jar',
+ );
+ });
+
+ test('does not mistake the Velocity JAR for the Paper JAR', () => {
+ expect(findPlatformAsset(VELOCITY_1_1_0, 'paper')).toBeNull();
+ });
+
+ test('returns null when the release carries no JAR for the platform', () => {
+ expect(findPlatformAsset(PAPER_1_2_2, 'velocity')).toBeNull();
+ });
+});
+
+describe('latestPlatformRelease', () => {
+ const releases = [UNIFIED_1_3_0, VELOCITY_1_1_0, PAPER_1_2_2];
+
+ test('prefers a newer unified release over an older platform-specific tag', () => {
+ expect(latestPlatformRelease(releases, 'paper')?.version).toBe('1.3.0');
+ expect(latestPlatformRelease(releases, 'velocity')?.version).toBe('1.2.0');
+ });
+
+ test('prefers a newer platform-specific tag over an older unified release', () => {
+ const paperOnly = release('paper/v1.4.0', '2026-09-01T00:00:00Z', [
+ 'LunaticChat-1.4.0.jar',
+ ]);
+ const withPaperOnly = [...releases, paperOnly];
+
+ expect(latestPlatformRelease(withPaperOnly, 'paper')?.version).toBe(
+ '1.4.0',
+ );
+ expect(latestPlatformRelease(withPaperOnly, 'velocity')?.version).toBe(
+ '1.2.0',
+ );
+ });
+
+ test('advances one platform without disturbing the other', () => {
+ const velocityOnly = release('velocity/v1.3.0', '2026-09-01T00:00:00Z', [
+ 'LunaticChat-1.3.0-velocity.jar',
+ ]);
+ const withVelocityOnly = [...releases, velocityOnly];
+
+ expect(latestPlatformRelease(withVelocityOnly, 'velocity')?.version).toBe(
+ '1.3.0',
+ );
+ expect(latestPlatformRelease(withVelocityOnly, 'paper')?.version).toBe(
+ '1.3.0',
+ );
+ });
+
+ test('ranks by publication time rather than by response order', () => {
+ const outOfOrder = [PAPER_1_2_2, UNIFIED_1_3_0];
+ expect(latestPlatformRelease(outOfOrder, 'paper')?.release.tag_name).toBe(
+ 'v1.3.0',
+ );
+ });
+
+ test('returns null when no release carries a JAR for the platform', () => {
+ expect(latestPlatformRelease([PAPER_1_2_2], 'velocity')).toBeNull();
+ });
+});
+
+describe('platformReleases', () => {
+ test('lists every release carrying the platform JAR, newest first', () => {
+ expect(
+ platformReleases([PAPER_1_2_2, UNIFIED_1_3_0], 'paper').map(
+ (e) => e.version,
+ ),
+ ).toEqual(['1.3.0', '1.2.2']);
+ });
+
+ test('excludes releases that ship only the other platform', () => {
+ expect(platformReleases([VELOCITY_1_1_0], 'paper')).toEqual([]);
+ });
+
+ test('lists a version once when a later unified release re-attaches its JAR', () => {
+ // v1.4.0 bumps Paper alone, so it re-attaches the unchanged Velocity 1.2.0 JAR.
+ const unified_1_4_0 = release('v1.4.0', '2026-09-01T00:00:00Z', [
+ 'LunaticChat-1.2.0-velocity.jar',
+ 'LunaticChat-1.4.0.jar',
+ ]);
+
+ expect(
+ platformReleases([UNIFIED_1_3_0, unified_1_4_0], 'velocity').map(
+ (e) => e.version,
+ ),
+ ).toEqual(['1.2.0']);
+ });
+
+ test('attributes a re-attached JAR to the release that shipped it last', () => {
+ const unified_1_4_0 = release('v1.4.0', '2026-09-01T00:00:00Z', [
+ 'LunaticChat-1.2.0-velocity.jar',
+ 'LunaticChat-1.4.0.jar',
+ ]);
+
+ expect(
+ platformReleases([UNIFIED_1_3_0, unified_1_4_0], 'velocity')[0]?.release
+ .tag_name,
+ ).toBe('v1.4.0');
+ });
+});
diff --git a/website/.vitepress/theme/components/releaseAssets.ts b/website/.vitepress/theme/components/releaseAssets.ts
new file mode 100644
index 0000000..f05977b
--- /dev/null
+++ b/website/.vitepress/theme/components/releaseAssets.ts
@@ -0,0 +1,78 @@
+export type Platform = 'paper' | 'velocity';
+
+export interface ReleaseAsset {
+ name: string;
+ size: number;
+ browser_download_url: string;
+}
+
+export interface GitHubRelease {
+ tag_name: string;
+ published_at: string;
+ html_url: string;
+ assets: ReleaseAsset[];
+}
+
+export interface PlatformAsset {
+ version: string;
+ asset: ReleaseAsset;
+}
+
+export interface PlatformRelease extends PlatformAsset {
+ release: GitHubRelease;
+}
+
+// A unified `vX.Y.Z` tag ships both platforms and their versions need not agree
+// (v1.3.0 carried Velocity 1.2.0), so a tag name can never stand in for a
+// platform version — only the JAR file name states it.
+const JAR_PATTERN: Record<Platform, RegExp> = {
+ paper: /^LunaticChat-(\d+\.\d+\.\d+)\.jar$/,
+ velocity: /^LunaticChat-(\d+\.\d+\.\d+)-velocity\.jar$/,
+};
+
+export function findPlatformAsset(
+ release: GitHubRelease,
+ platform: Platform,
+): PlatformAsset | null {
+ for (const asset of release.assets) {
+ const version = JAR_PATTERN[platform].exec(asset.name)?.[1];
+ if (version) return { version, asset };
+ }
+ return null;
+}
+
+export function platformReleases(
+ releases: GitHubRelease[],
+ platform: Platform,
+): PlatformRelease[] {
+ // The API orders releases by tag creation, not by publication — velocity/v1.1.0
+ // precedes the later-published paper/v1.2.2 — so response order cannot decide
+ // which release is the newest.
+ const found = releases
+ .flatMap((release) => {
+ const asset = findPlatformAsset(release, platform);
+ return asset ? [{ release, ...asset }] : [];
+ })
+ .sort(
+ (a, b) =>
+ Date.parse(b.release.published_at) - Date.parse(a.release.published_at),
+ );
+
+ // A unified `vX.Y.Z` release always attaches both JARs, so a platform version
+ // reappears under a new tag whenever only the other platform was bumped. The
+ // newest publication is the one that shipped, and keeping both would list the
+ // same version twice — with two protocol versions when the protocol moved.
+ const seen = new Set<string>();
+ return found.filter((entry) => {
+ if (seen.has(entry.version)) return false;
+ seen.add(entry.version);
+ return true;
+ });
+}
+
+export function latestPlatformRelease(
+ releases: GitHubRelease[],
+ platform: Platform,
+): PlatformRelease | null {
+ return platformReleases(releases, platform)[0] ?? null;
+}
diff --git a/website/.vitepress/theme/components/useCompatibilityData.ts b/website/.vitepress/theme/components/useCompatibilityData.ts
index d0de01d..2952355 100644
--- a/website/.vitepress/theme/components/useCompatibilityData.ts
+++ b/website/.vitepress/theme/components/useCompatibilityData.ts
@@ -1,15 +1,14 @@
-import { ref, onMounted } from 'vue';
+import { onMounted, ref } from 'vue';
+import {
+ type GitHubRelease,
+ type Platform,
+ platformReleases,
+} from './releaseAssets';
const REPO = 'm1sk9/LunaticChat';
const PROTOCOL_FILE_PATH =
'engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt';
-interface GitHubRelease {
- tag_name: string;
- published_at: string;
- html_url: string;
-}
-
export interface ProtocolVersion {
major: number;
minor: number;
@@ -18,7 +17,7 @@ export interface ProtocolVersion {
}
export interface PlatformReleaseEntry {
- platform: 'paper' | 'velocity';
+ platform: Platform;
version: string;
tag: string;
publishedAt: string;
@@ -55,7 +54,9 @@ function parseProtocolVersion(source: string): ProtocolVersion | null {
return { major, minor, patch, minSupportedMinor };
}
-async function fetchProtocolAtTag(tag: string): Promise<ProtocolVersion | null> {
+async function fetchProtocolAtTag(
+ tag: string,
+): Promise<ProtocolVersion | null> {
const url = `https://raw.githubusercontent.com/${REPO}/${encodeURIComponent(tag)}/${PROTOCOL_FILE_PATH}`;
try {
const res = await fetch(url);
@@ -67,19 +68,18 @@ async function fetchProtocolAtTag(tag: string): Promise<ProtocolVersion | null>
}
}
-function buildEntry(
- release: GitHubRelease,
- platform: 'paper' | 'velocity',
-): PlatformReleaseEntry {
- const version = release.tag_name.replace(/^(paper\/|velocity\/)?v/, '');
- return {
+function buildEntries(
+ releases: GitHubRelease[],
+ platform: Platform,
+): PlatformReleaseEntry[] {
+ return platformReleases(releases, platform).map((entry) => ({
platform,
- version,
- tag: release.tag_name,
- publishedAt: release.published_at,
- releaseUrl: release.html_url,
+ version: entry.version,
+ tag: entry.release.tag_name,
+ publishedAt: entry.release.published_at,
+ releaseUrl: entry.release.html_url,
protocol: null,
- };
+ }));
}
export function useCompatibilityData() {
@@ -99,18 +99,8 @@ export function useCompatibilityData() {
const releases: GitHubRelease[] = await res.json();
- const isUnified = (tag: string) => /^v\d/.test(tag);
- const isPaperTag = (tag: string) =>
- tag.startsWith('paper/v') || isUnified(tag);
- const isVelocityTag = (tag: string) =>
- tag.startsWith('velocity/v') || isUnified(tag);
-
- const paper = releases
- .filter((r) => isPaperTag(r.tag_name))
- .map((r) => buildEntry(r, 'paper'));
- const velocity = releases
- .filter((r) => isVelocityTag(r.tag_name))
- .map((r) => buildEntry(r, 'velocity'));
+ const paper = buildEntries(releases, 'paper');
+ const velocity = buildEntries(releases, 'velocity');
const all = [...paper, ...velocity];
const protocols = await Promise.all(
@@ -134,7 +124,11 @@ export function useCompatibilityData() {
return { data, loading, error };
}
-export type CompatibilityResult = 'compatible' | 'major-mismatch' | 'paper-too-new' | 'paper-too-old';
+export type CompatibilityResult =
+ | 'compatible'
+ | 'major-mismatch'
+ | 'paper-too-new'
+ | 'paper-too-old';
// Mirrors the gatekeeping done by Velocity in
// platform-velocity/.../PluginMessageHandler.kt — Paper does not validate.
diff --git a/website/.vitepress/theme/components/useDownloadData.ts b/website/.vitepress/theme/components/useDownloadData.ts
index 0c9e7e0..8ed264a 100644
--- a/website/.vitepress/theme/components/useDownloadData.ts
+++ b/website/.vitepress/theme/components/useDownloadData.ts
@@ -1,27 +1,19 @@
-import { ref, onMounted } from 'vue';
+import { onMounted, ref } from 'vue';
+import {
+ type GitHubRelease,
+ latestPlatformRelease,
+ type Platform,
+} from './releaseAssets';
const REPO = 'm1sk9/LunaticChat';
-interface ReleaseAsset {
- name: string;
- size: number;
- browser_download_url: string;
-}
-
-interface GitHubRelease {
- tag_name: string;
- published_at: string;
- html_url: string;
- assets: ReleaseAsset[];
-}
-
export interface PlatformRelease {
version: string;
publishedAt: string;
releaseUrl: string;
- downloadUrl: string | null;
- fileName: string | null;
- fileSize: number | null;
+ downloadUrl: string;
+ fileName: string;
+ fileSize: number;
}
export interface DownloadData {
@@ -30,22 +22,20 @@ export interface DownloadData {
ci: { url: string };
}
-function parsePlatformRelease(
- release: GitHubRelease | null,
- jarPattern: RegExp,
+function resolvePlatformRelease(
+ releases: GitHubRelease[],
+ platform: Platform,
): PlatformRelease | null {
- if (!release) return null;
-
- const asset = release.assets.find((a) => jarPattern.test(a.name));
- const version = release.tag_name.replace(/^(paper\/|velocity\/)?v/, '');
+ const latest = latestPlatformRelease(releases, platform);
+ if (!latest) return null;
return {
- version,
- publishedAt: release.published_at,
- releaseUrl: release.html_url,
- downloadUrl: asset?.browser_download_url ?? null,
- fileName: asset?.name ?? null,
- fileSize: asset?.size ?? null,
+ version: latest.version,
+ publishedAt: latest.release.published_at,
+ releaseUrl: latest.release.html_url,
+ downloadUrl: latest.asset.browser_download_url,
+ fileName: latest.asset.name,
+ fileSize: latest.asset.size,
};
}
@@ -72,25 +62,9 @@ export function useDownloadData() {
const releases: GitHubRelease[] = await res.json();
- const paperRelease =
- releases.find((r) => r.tag_name.startsWith('paper/v')) ??
- releases.find((r) => /^v\d/.test(r.tag_name)) ??
- null;
-
- const velocityRelease =
- releases.find((r) => r.tag_name.startsWith('velocity/v')) ??
- releases.find((r) => /^v\d/.test(r.tag_name)) ??
- null;
-
data.value = {
- paper: parsePlatformRelease(
- paperRelease,
- /^LunaticChat-[\d.]+\.jar$/,
- ),
- velocity: parsePlatformRelease(
- velocityRelease,
- /^LunaticChat-[\d.]+-velocity\.jar$/,
- ),
+ paper: resolvePlatformRelease(releases, 'paper'),
+ velocity: resolvePlatformRelease(releases, 'velocity'),
ci: data.value.ci,
};
} catch {
diff --git a/website/package.json b/website/package.json
index 48c2a6b..5b18155 100644
--- a/website/package.json
+++ b/website/package.json
@@ -3,6 +3,7 @@
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview",
+ "test": "bun test ./.vitepress/",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "biome lint .",