diff --git a/src/lib/artifact-version.ts b/src/lib/artifact-version.ts new file mode 100644 index 0000000..a5d03ff --- /dev/null +++ b/src/lib/artifact-version.ts @@ -0,0 +1,7 @@ +/** 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): string { + if (!version) return '—'; + return version.release_version + ? `${version.release_version} (upload ${version.version})` + : `v${version.version}`; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index fb48a5a..ec13947 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -65,6 +65,7 @@ const SCRIPTS_COLUMNS: Array<{ name: string; ddl: string }> = [ ]; const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [ + { name: 'release_version', ddl: 'ALTER TABLE script_versions ADD COLUMN release_version TEXT' }, // state was added post-launch to make untested/tested/test-failed visible // in the UI. Existing rows default to 'untested' — this is safe because // any real promoted version's script has current_version_id set (which is diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index d8db5cf..24f9cca 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS script_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, version INTEGER NOT NULL, + release_version TEXT, -- publisher's version/tag, independent of upload order sha256 TEXT NOT NULL, size INTEGER NOT NULL, content BLOB NOT NULL, diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index f4755d0..b3208a8 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -29,6 +29,7 @@ export interface ScriptVersionRow { id: number; script_id: number; version: number; + release_version: string | null; sha256: string; size: number; content: Buffer; @@ -66,6 +67,7 @@ export class ScriptError extends Error { | 'baseline-requires-scope' | 'baseline-exists' | 'empty-overlay' + | 'invalid-release-version' ) { super(message); } @@ -75,6 +77,14 @@ function sha256(buf: Buffer): string { return createHash('sha256').update(buf).digest('hex'); } +function normalizeReleaseVersion(value: string | null | undefined): string | null { + if (value == null) return null; + if (typeof value !== 'string' || value.length > 80 || /[\u0000-\u001f\u007f]/.test(value)) { + throw new ScriptError('release version must be a single line of at most 80 characters', 'invalid-release-version'); + } + return value.trim() || null; +} + export function getScript(id: number): ScriptRow | undefined { return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined; } @@ -157,6 +167,8 @@ export interface CreateScriptInput { scopeValue?: string | null; content: Buffer; notes?: string; + /** Publisher's release tag, e.g. v3.1.0. Upload numbering stays independent. */ + releaseVersion?: string; /** Defaults to 'script' for back-compat with existing callers. */ kind?: ArtifactKind; } @@ -206,6 +218,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken'); const sha = sha256(input.content); + const releaseVersion = normalizeReleaseVersion(input.releaseVersion); const size = input.content.byteLength; const insertScript = db().prepare( @@ -213,8 +226,8 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver VALUES (?, ?, ?, ?, ?)` ); const insertVersion = db().prepare( - `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind) - VALUES (?, 1, ?, ?, ?, ?, ?)` + `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version) + VALUES (?, 1, ?, ?, ?, ?, ?, ?)` ); let scriptId!: number; @@ -222,7 +235,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver db().transaction(() => { const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind); scriptId = Number(s.lastInsertRowid); - const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind); + const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind, releaseVersion); versionId = Number(v.lastInsertRowid); })(); @@ -232,7 +245,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver }; } -export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string): ScriptVersionRow { +export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string, releaseVersion?: string): ScriptVersionRow { const script = getScript(scriptId); if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script'); @@ -249,13 +262,14 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri const latest = getLatestVersion(scriptId); const nextVersion = (latest?.version ?? 0) + 1; const sha = sha256(content); + const release = normalizeReleaseVersion(releaseVersion); const res = db() .prepare( - `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind) - VALUES (?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) - .run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind); + .run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind, release); return getVersion(Number(res.lastInsertRowid))!; } diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index b8e4536..1fa9ad0 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,5 +1,6 @@