Require release versions for script uploads
CI / checks (push) Successful in 5m26s

This commit is contained in:
2026-10-12 18:46:57 -04:00
parent ffda04f276
commit 2f52dcb1ef
50 changed files with 871 additions and 113 deletions
+42 -9
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
@@ -69,7 +71,13 @@
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let installedVersions = $state<
Array<{ server: string; version: number; versionId: number; recordedAt: string }>
Array<{
server: string;
version: number;
versionId: number;
release_version: string | null;
recordedAt: string;
}>
>([]);
interface ScopeImpact {
before: string[];
@@ -103,6 +111,7 @@
let versionFile = $state<File | null>(null);
let versionNotes = $state('');
let releaseVersion = $state('');
let versionManifest = $state<Record<string, string>>({});
let uploadingVersion = $state(false);
let scopeDialog = $state<HTMLDialogElement | null>(null);
@@ -244,6 +253,7 @@
function openUploadModal(): void {
if (!editable || runningBetaId !== null) return;
releaseVersion = '';
versionManifest = {};
versionFile = null;
versionNotes = '';
flash = null;
@@ -271,16 +281,23 @@
function pickVersionFile(e: Event): void {
const t = e.target as HTMLInputElement;
versionFile = t.files?.[0] ?? null;
if (!releaseVersion.trim() && versionFile)
releaseVersion = manifestVersion(versionManifest, versionFile.name);
}
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!editable || !versionFile || uploadingVersion) return;
if (family === 'script' && scriptVersionError(releaseVersion)) {
flash = scriptVersionError(releaseVersion);
return;
}
uploadingVersion = true;
flash = null;
const form = new FormData();
form.append('content', versionFile);
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
if (family === 'plugin' || family === 'script')
form.append('releaseVersion', releaseVersion.trim());
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
try {
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
@@ -419,7 +436,7 @@
{#if latestVersion}<Card padding="lg"
><div class="release-summary">
<div>
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
<StatusBadge
state={latestVersion.id === a.current_version_id
? 'promoted'
@@ -517,7 +534,7 @@
<span class="k">Promoted version</span>
<span class="v mono">
{#if currentVersion}
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
0,
8
)}
@@ -541,7 +558,9 @@
<a href={'/servers/' + encodeURIComponent(installed.server)}
>{serverTitle(installed.server)}</a
>
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
· {artifactVersionLabel(installed, family)} · <RelativeTime
value={installed.recordedAt}
/>
</li>{/each}
</ul>
</details>{:else if family !== 'config'}<p class="dim">
@@ -575,7 +594,7 @@
<div class="version-list">
{#each versions.slice(0, limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-num">{artifactVersionLabel(v, family)}</span>
<span class="v-notes"
>{v.notes ?? ''}
{#if v.source}<a
@@ -750,14 +769,28 @@
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
{#if family === 'script'}
<ScriptVersionManifest
disabled={uploadingVersion}
onload={(versions) => {
versionManifest = versions;
if (versionFile && !releaseVersion.trim())
releaseVersion = manifestVersion(versions, versionFile.name);
}}
/>
{/if}
{#if family === 'plugin' || family === 'script'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
hint={family === 'script'
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
: 'Optional. Use the publisher’s version or release tag, such as v3.1.0.'}
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
required={family === 'script'}
maxlength={80}
disabled={uploadingVersion}
/>
</Field>
@@ -1,4 +1,8 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import DiffViewer from './DiffViewer.svelte';
@@ -31,19 +35,38 @@
} = $props();
interface Replacement {
file: File;
releaseVersion: string;
sha: string;
before: string | null;
after: string | null;
}
let manifest = $state<Record<string, string>>({});
let replacements = $state<Record<number, Replacement>>({});
let notes = $state(''),
error = $state(''),
reviewing = $state(false),
busy = $state(false);
const versionsValid = $derived(
files.every(
(entry) =>
!replacements[entry.id] ||
entry.kind !== 'script' ||
!scriptVersionError(replacements[entry.id].releaseVersion)
)
);
function applyManifest(versions: Record<string, string>) {
manifest = versions;
for (const entry of files) {
const replacement = replacements[entry.id];
if (entry.kind === 'script' && replacement && !replacement.releaseVersion.trim())
replacement.releaseVersion = manifestVersion(manifest, replacement.file.name);
}
}
const count = $derived(Object.keys(replacements).length);
$effect(() => {
if (open) {
replacements = {};
manifest = {};
notes = '';
error = '';
reviewing = false;
@@ -90,7 +113,13 @@
after = new TextDecoder().decode(bytes);
}
}
replacements[entry.id] = { file, sha, before, after };
replacements[entry.id] = {
file,
sha,
before,
after,
releaseVersion: entry.kind === 'script' ? manifestVersion(manifest, file.name) : ''
};
} catch (err) {
error = (err as Error).message;
} finally {
@@ -98,14 +127,17 @@
}
}
async function deploy() {
if (!versionsValid) return;
busy = true;
error = '';
try {
const form = new FormData();
form.set('expectedRevision', String(revision));
form.set('notes', notes);
for (const [scriptId, replacement] of Object.entries(replacements))
for (const [scriptId, replacement] of Object.entries(replacements)) {
form.set(`file:${scriptId}`, replacement.file);
form.set(`release:${scriptId}`, replacement.releaseVersion.trim());
}
const response = await fetch(`/api/betas/${betaId}/revisions`, {
method: 'POST',
body: form
@@ -132,12 +164,15 @@
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if !reviewing}
<p>Choose replacements for the files you changed.</p>
{#if files.some((entry) => entry.kind === 'script')}
<ScriptVersionManifest disabled={busy} onload={applyManifest} />
{/if}
<div class="files">
{#each files as entry}
<label
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
>{entry.target_rel_path}</code
>
><strong
>{entry.name} · {artifactVersionLabel(entry.version, entry.kind)}</strong
><code>{entry.target_rel_path}</code>
<input
type="file"
disabled={busy}
@@ -145,6 +180,26 @@
/>
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
</label>
{#if replacements[entry.id] && (entry.kind === 'script' || entry.kind === 'plugin')}
<Field
label={`${entry.name} release version`}
hint={entry.kind === 'script'
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
: 'Optional publisher release tag.'}
>
<Input
bind:value={replacements[entry.id].releaseVersion}
required={entry.kind === 'script'}
maxlength={80}
disabled={busy}
/>
</Field>
{#if entry.kind === 'script' && scriptVersionError(replacements[entry.id].releaseVersion)}
<p class="ui-error">
{scriptVersionError(replacements[entry.id].releaseVersion)}
</p>
{/if}
{/if}
{/each}
</div>
<label class="notes"
@@ -177,6 +232,9 @@
{@const replacement = replacements[entry.id]}
<section>
<h3>{entry.name}</h3>
{#if replacement.releaseVersion}<p>
Release {entry.version?.release_version ?? 'Unversioned'} → {replacement.releaseVersion.trim()}
</p>{/if}
<p>
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
bytes)
@@ -204,7 +262,7 @@
{:else}<Button
variant="primary"
onclick={() => (reviewing = true)}
disabled={busy || !count}>Review update</Button
disabled={busy || !count || !versionsValid}>Review update</Button
>{/if}
{/snippet}
</Dialog>
+9 -3
View File
@@ -10,15 +10,21 @@
let {
current,
latest,
kind,
betaId = null
}: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
}: {
current: Version | null;
latest: Version | null;
betaId?: number | null;
kind?: string;
} = $props();
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
const ambiguous = $derived(
!!newer?.release_version && newer.release_version === current?.release_version
);
function label(version: Version) {
return ambiguous
? artifactVersionLabel(version)
return kind === 'script' || ambiguous
? artifactVersionLabel(version, kind)
: version.release_version || `v${version.version}`;
}
const state = $derived(
@@ -1,4 +1,7 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
@@ -20,23 +23,34 @@
scriptId: number;
versionId: number;
version: number;
release_version: string | null;
}
interface Upload {
key: number;
file: File;
target: number | 'new' | '';
name: string;
releaseVersion: string;
duplicates: Duplicate[];
inspecting: boolean;
inspected: boolean;
error: string;
result: (BetaSelection & { version: number; reused: boolean }) | null;
result:
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
let input: HTMLInputElement;
let queue = $state<Upload[]>([]);
let busy = $state(false);
let notes = $state('');
let manifest = $state<Record<string, string>>({});
function applyManifest(versions: Record<string, string>) {
manifest = versions;
for (const item of queue) {
if (!item.result && !item.releaseVersion.trim())
item.releaseVersion = manifestVersion(manifest, item.file.name);
}
}
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
@@ -80,6 +94,7 @@
// only when there is no pending file to lose.
if (!pending.length) {
queue = [];
manifest = {};
notes = '';
message = '';
preferredTarget = scriptId ?? null;
@@ -121,6 +136,7 @@
preferredTarget ??
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''),
releaseVersion: manifestVersion(manifest, file.name),
duplicates: [],
inspecting: true,
inspected: false,
@@ -153,9 +169,13 @@
}
}
function duplicate(item: Upload) {
return item.duplicates.find((d) => d.scriptId === item.target);
return item.duplicates.find(
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
);
}
function validation(item: Upload): string {
const versionError = scriptVersionError(item.releaseVersion);
if (versionError) return versionError;
if (item.target === '')
return 'Several scripts use this filename. Choose which script to update.';
if (item.target === 'new') {
@@ -194,6 +214,7 @@
const form = new FormData();
form.append('content', item.file);
form.append('notes', notes.trim());
form.append('releaseVersion', item.releaseVersion.trim());
const creating = item.target === 'new';
if (creating) {
form.append('name', item.name.trim());
@@ -211,6 +232,7 @@
scriptId: creating ? body.script.id : (item.target as number),
versionId: creating ? body.versionId : body.version.id,
version: creating ? 1 : body.version.version,
release_version: item.releaseVersion.trim(),
reused: !!body.reused
};
} catch (e) {
@@ -286,6 +308,7 @@
}}
/>
</div>
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
<div class="queue" aria-live="polite">
{#each queue as item (item.key)}
<section class="upload-item" aria-label={item.file.name}>
@@ -304,11 +327,26 @@
</div>
{#if item.result}
<p class="success">
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
.version} ·
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
item.result,
'script'
)} ·
<a href="/scripts/{item.result.scriptId}">View script →</a>
</p>
{:else}
<Field
label="Release version"
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
>
<Input
bind:value={item.releaseVersion}
required
maxlength={80}
placeholder="1.2.0"
disabled={busy}
oninput={() => (item.error = '')}
/>
</Field>
<Field label="Destination">
<Select
bind:value={item.target}
@@ -336,8 +374,10 @@
</p>{/if}
{#if item.inspecting}<p class="note">Checking file contents…</p>
{:else if duplicate(item)}<p class="success">
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
version.
Already uploaded as {artifactVersionLabel(
duplicate(item),
'script'
)}. We’ll reuse that version.
</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
New version for {scripts.find((s) => s.id === item.target)?.name}
@@ -0,0 +1,44 @@
<script lang="ts">
import { parseScriptVersions } from '$lib/script-version.js';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
let {
disabled = false,
onload
}: {
disabled?: boolean;
onload: (versions: Record<string, string>) => void;
} = $props();
let message = $state('');
let error = $state('');
let loading = $state(false);
async function choose(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
loading = true;
message = error = '';
try {
if (file.size > 1024 * 1024)
throw new Error('versions.json must be smaller than 1 MiB.');
const versions = parseScriptVersions(await file.text());
onload(versions);
message = `Loaded ${Object.keys(versions).length} script versions. Review the matched values below.`;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
</script>
<Field
label="Version manifest (optional)"
hint="Choose versions.json from the same source checkout as your files. Matches exact filenames; fills empty release version fields."
>
<Input type="file" accept=".json" disabled={disabled || loading} onchange={choose} />
</Field>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}