diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md index 0099637..b3e0a0a 100644 --- a/docs/guides/beta-test-plans.md +++ b/docs/guides/beta-test-plans.md @@ -429,7 +429,13 @@ recent evidence under `test`. copied into a beta; later edits do not change an active plan. `POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional -`notes`, and replacement uploads named `file:`. A stale revision is +`notes`, and replacement uploads named `file:`. Each script replacement +also requires `release:` in `MAJOR.MINOR.PATCH` form. The update dialog +accepts manual versions or `versions.json` to fill empty fields by exact filename. +A changed script cannot reuse a previous release version. Missing or invalid +versions reject the entire update without modifying beta history or deployments. +Plugin release tags remain optional. See [script release versions](getting-started.md#script-release-versions). +A stale revision is rejected. `GET /api/betas/:id` includes the current `revision` and `revisions` history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an archived file (or the original pinned file before its first update). diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 4b27e8b..e2a981b 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -108,6 +108,35 @@ servers. Disruptive operations normally wait for a fresh empty lobby or a verified stopped container. A rebuild removes the instance's writable files. See [Provisioning](provisioning.md) and [Collections](collections.md) for details. +## Script release versions + +Every new script upload needs a release version in `MAJOR.MINOR.PATCH` form, +for example `1.2.0`. Enter a version for each file in the upload drawer, script +page, or beta replacement dialog. Versions belong to individual scripts; a batch +can contain different versions. + +To fill empty fields automatically, choose `versions.json` from the same source +checkout as your scripts. Cherry matches exact, case-sensitive filenames (such as +`tws_t5_hud.gsc`) and leaves entered values intact. Review the values before +uploading. A manifest identifies source releases; it does not verify file contents. + +Changing a script requires a new release version. Reuploading identical bytes with +the same version reuses the saved release; different bytes with that version are +rejected. Older release versions can be imported. Cherry keeps its upload number +separately, displaying labels such as `1.2.0 (upload 7)`. + +Historical scripts without a release label display `Unversioned (upload N)` and +remain available for testing, promotion, and rollback. Cherry does not infer old +versions from today's manifest or rewrite historical records. Uploading the same +bytes with an explicit version creates a separate labeled record when needed. +Plugins retain optional publisher tags; configurations retain automatic upload +numbers. + +API clients must include multipart `releaseVersion` when creating a script with +`POST /api/scripts` or uploading with `POST /api/scripts/:id/versions`. Missing or +invalid versions return HTTP 400; conflicting contents return HTTP 409. The +version upload response reports `reused: true` for an existing identical release. + ## Connect game telemetry Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an diff --git a/src/lib/artifact-version.ts b/src/lib/artifact-version.ts index 88164b3..66d76eb 100644 --- a/src/lib/artifact-version.ts +++ b/src/lib/artifact-version.ts @@ -1,9 +1,12 @@ /** Keep the upload number visible: two builds may share a publisher's release tag. */ export function artifactVersionLabel( - version: { version: number; release_version?: string | null } | null | undefined + version: { version: number; release_version?: string | null } | null | undefined, + kind?: string ): string { if (!version) return '—'; return version.release_version ? `${version.release_version} (upload ${version.version})` - : `v${version.version}`; + : kind === 'script' + ? `Unversioned (upload ${version.version})` + : `v${version.version}`; } diff --git a/src/lib/script-library.ts b/src/lib/script-library.ts index 9a63989..9fb5d2d 100644 --- a/src/lib/script-library.ts +++ b/src/lib/script-library.ts @@ -4,6 +4,7 @@ import { gameTitle } from './ui/server-labels.js'; export interface LibraryVersion { id: number; version: number; + release_version?: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; diff --git a/src/lib/script-version.ts b/src/lib/script-version.ts new file mode 100644 index 0000000..f380155 --- /dev/null +++ b/src/lib/script-version.ts @@ -0,0 +1,33 @@ +/** TWS source releases use three numeric components, without tags or suffixes. */ +export function scriptVersionError(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.'; + if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim())) + return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.'; + return ''; +} + +/** Ignore non-script components (for example native plugins) in a TWS manifest. */ +export function parseScriptVersions(text: string): Record { + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new Error('Choose a valid versions.json file.'); + } + if (!data || typeof data !== 'object' || Array.isArray(data)) + throw new Error('versions.json must map script filenames to release versions.'); + const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc')); + if (!entries.length) throw new Error('versions.json contains no .gsc filenames.'); + for (const [name, value] of entries) { + if (name.includes('/') || name.includes('\\')) + throw new Error(`Use an exact filename in versions.json: ${name}`); + const error = scriptVersionError(value); + if (error) throw new Error(`${name}: ${error}`); + } + return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()])); +} + +/** Uploaded filenames must never resolve inherited object properties. */ +export function manifestVersion(versions: Record, filename: string): string { + return Object.hasOwn(versions, filename) ? versions[filename] : ''; +} diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 05797a1..f3af32c 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -1,3 +1,4 @@ +import { scriptVersionError } from '../../script-version.js'; import { putFileBlob, blobContent } from '../db/file-blobs.js'; import { createHash } from 'node:crypto'; import { db } from '../db/index.js'; @@ -73,6 +74,7 @@ export class ScriptError extends Error { | 'baseline-exists' | 'empty-overlay' | 'invalid-release-version' + | 'release-version-conflict' | 'too-large' ) { super(message); @@ -95,6 +97,14 @@ function normalizeReleaseVersion(value: string | null | undefined): string | nul return value.trim() || null; } +function releaseForKind(kind: ArtifactKind, value: string | null | undefined): string | null { + if (kind === 'script') { + const error = scriptVersionError(value); + if (error) throw new ScriptError(error, 'invalid-release-version'); + } + return normalizeReleaseVersion(value); +} + export function getScript(id: number): ScriptRow | undefined { return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined; } @@ -273,7 +283,7 @@ export function createScript(input: CreateScriptInput): { throw new ScriptError(`script "${input.name}" already exists`, 'name-taken'); const sha = sha256(input.content); - const releaseVersion = normalizeReleaseVersion(input.releaseVersion); + const releaseVersion = releaseForKind(kind, input.releaseVersion); const size = input.content.byteLength; const insertScript = db().prepare( @@ -335,27 +345,39 @@ export function uploadNewVersion( } } - const latest = getLatestVersion(scriptId); - const nextVersion = (latest?.version ?? 0) + 1; + const release = releaseForKind(script.kind, releaseVersion); const sha = sha256(content); - const release = normalizeReleaseVersion(releaseVersion); + return db().transaction(() => { + if (script.kind === 'script') { + const existing = listVersions(scriptId).filter((v) => v.release_version === release); + if (existing.some((v) => v.sha256 !== sha)) { + throw new ScriptError( + `Release ${release} already has different contents. Bump the script release version.`, + 'release-version-conflict' + ); + } + if (existing.length) return existing[0]; + } + const latest = getLatestVersion(scriptId); + const nextVersion = (latest?.version ?? 0) + 1; - const res = db() - .prepare( - `INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version) + const res = db() + .prepare( + `INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - scriptId, - nextVersion, - sha, - content.byteLength, - putFileBlob(content), - notes ?? null, - script.kind, - release - ); - return getVersion(Number(res.lastInsertRowid))!; + ) + .run( + scriptId, + nextVersion, + sha, + content.byteLength, + putFileBlob(content), + notes ?? null, + script.kind, + release + ); + return getVersion(Number(res.lastInsertRowid))!; + })(); } function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void { diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index ec844b0..39200f3 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,4 +1,6 @@ + + + + +{#if error}{/if} +{#if message}

{message}

{/if} diff --git a/src/routes/api/betas/[id]/revisions/+server.ts b/src/routes/api/betas/[id]/revisions/+server.ts index e5f675a..22934d7 100644 --- a/src/routes/api/betas/[id]/revisions/+server.ts +++ b/src/routes/api/betas/[id]/revisions/+server.ts @@ -39,7 +39,14 @@ export async function POST({ params, request }: { params: { id: string }; reques if (isHttpError(error)) throw error; return json( { ok: false, error: (error as Error).message }, - { status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 } + { + status: + error instanceof ScriptError && error.kind === 'invalid-release-version' + ? 400 + : error instanceof BetaError || error instanceof ScriptError + ? 409 + : 400 + } ); } } diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts index 1204b86..315c786 100644 --- a/src/routes/api/scripts/+server.ts +++ b/src/routes/api/scripts/+server.ts @@ -89,7 +89,10 @@ export async function POST({ request, url }: { request: Request; url: URL }) { return json({ ok: true, script, versionId: version.id }); } catch (err) { if (err instanceof ScriptError) { - return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 }); + return json( + { ok: false, error: err.message, kind: err.kind }, + { status: err.kind === 'invalid-release-version' ? 400 : 409 } + ); } throw err; } diff --git a/src/routes/api/scripts/[id]/+server.ts b/src/routes/api/scripts/[id]/+server.ts index 9c118ab..6e6ceba 100644 --- a/src/routes/api/scripts/[id]/+server.ts +++ b/src/routes/api/scripts/[id]/+server.ts @@ -50,7 +50,7 @@ export async function GET({ params }: { params: { id: string } }) { script.kind === 'script' || script.kind === 'plugin' ? db() .prepare( - `SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server` + `SELECT i.server, v.version, v.release_version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server` ) .all(id, script.target_rel_path) : [], diff --git a/src/routes/api/scripts/[id]/versions/+server.ts b/src/routes/api/scripts/[id]/versions/+server.ts index 89cb283..4a690c8 100644 --- a/src/routes/api/scripts/[id]/versions/+server.ts +++ b/src/routes/api/scripts/[id]/versions/+server.ts @@ -1,8 +1,11 @@ import { artifactFormBody } from '$lib/server/request-body.js'; import { json } from '@sveltejs/kit'; -import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js'; -import { createHash } from 'node:crypto'; -import { db } from '$lib/server/db/index.js'; +import { + uploadNewVersion, + getScript, + getLatestVersion, + ScriptError +} from '$lib/server/fleet/scripts.js'; import { findRunningBetaForScript } from '$lib/server/fleet/beta.js'; function parseId(idRaw: string): number | null { @@ -11,7 +14,7 @@ function parseId(idRaw: string): number | null { } // Multipart POST — creates a new version from an uploaded file. Fields: -// content (file), notes and releaseVersion (optional). Response includes a "betaRequired" hint +// content (file), notes (optional) and releaseVersion (required for scripts). Response includes a "betaRequired" hint // when the script has a scope so the UI can nudge the operator to open a beta. export async function POST({ params, request }: { params: { id: string }; request: Request }) { const scriptId = parseId(params.id); @@ -45,21 +48,16 @@ export async function POST({ params, request }: { params: { id: string }; reques { status: 409 } ); } - const sha = createHash('sha256').update(content).digest('hex'); - const existing = db() - .prepare( - 'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1' - ) - .get(scriptId, sha); - if (existing) return json({ ok: true, version: existing, reused: true }); } try { const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined; + const previous = getLatestVersion(scriptId); const version = uploadNewVersion(scriptId, content, notes, releaseVersion); return json({ ok: true, + reused: script.kind === 'script' && version.version <= (previous?.version ?? 0), version: { id: version.id, version: version.version, @@ -73,7 +71,10 @@ export async function POST({ params, request }: { params: { id: string }; reques }); } catch (err) { if (err instanceof ScriptError) { - return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 }); + return json( + { ok: false, error: err.message, kind: err.kind }, + { status: err.kind === 'invalid-release-version' ? 400 : 409 } + ); } throw err; } diff --git a/src/routes/api/scripts/inspect/+server.ts b/src/routes/api/scripts/inspect/+server.ts index 180c81f..ea5ae2e 100644 --- a/src/routes/api/scripts/inspect/+server.ts +++ b/src/routes/api/scripts/inspect/+server.ts @@ -15,7 +15,7 @@ export async function POST({ request }: { request: Request }) { .digest('hex'); const duplicates = db() .prepare( - `SELECT v.script_id AS scriptId, v.id AS versionId, v.version + `SELECT v.script_id AS scriptId, v.id AS versionId, v.version, v.release_version FROM script_versions v JOIN scripts s ON s.id = v.script_id WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC` ) diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 2794536..9cf2885 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -261,7 +261,11 @@ > {#if beta.scripts.length === 1}{beta.scripts[0].name} - {artifactVersionLabel(beta.scripts[0].version)}{artifactVersionLabel( + beta.scripts[0].version, + beta.scripts[0].kind + )}{:else}{beta.scripts.length} files{/if} {file.name} - · {artifactVersionLabel(file.version)}

{file.target_rel_path}

{#if file.version}

@@ -661,7 +661,10 @@ {file.name} · {artifactVersionLabel(file)}{file.name} · {artifactVersionLabel( + file, + file.kind + )}{file.path}{file.sha256.slice(0, 12)} @@ -744,7 +747,8 @@

{#each preview.files as file}
{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers{file.name} · {artifactVersionLabel(file, file.kind)} · {file.servers + .length} servers

{file.path} · {file.scope} · SHA {file.sha256.slice(0, 12)}

    diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte index 411bda4..2ab515c 100644 --- a/src/routes/betas/new/+page.svelte +++ b/src/routes/betas/new/+page.svelte @@ -712,10 +712,14 @@ ? 'Loading versions…' : 'Choose a version'}{#each versionsFor(pick) as version}{artifactVersionLabel( + version, + scripts.find((file) => file.id === pick.scriptId) + ?.kind + )} · {version.sha256.slice(0, 8)} · {version.state.replaceAll( + '-', + ' ' + )}{/each} @@ -926,7 +930,8 @@ ?.name} · {artifactVersionLabel( - versionsFor(pick).find((v) => v.id === pick.versionId) + versionsFor(pick).find((v) => v.id === pick.versionId), + scripts.find((file) => file.id === pick.scriptId)?.kind )} · {versionsFor(pick) .find((v) => v.id === pick.versionId) ?.sha256.slice( diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index 1c64720..6c78304 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -1,4 +1,5 @@