Promote as a real deployment; version state and scope dropdowns
Turns promotion into a first-class deployment (action='promote') so it lands in the deployments UI with before/after content, per-server restart tracking, and one-click rollback. Non-beta matching-scope servers get restart_state= pending so the existing sweep drains and restarts them the same way drift does. Also adds a per-version state (untested/tested/test-failed) so the upload flow can visibly signal that a beta must land before promotion, and swaps the scope-value text box for game/map dropdowns populated from the fleet. Beta rollback at ready-promote is now labelled Reject and marks the version test-failed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -331,6 +331,26 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
db()
|
||||
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
|
||||
.run(nowIso(), betaId);
|
||||
|
||||
// Mark the version test-failed unless another beta for the same version
|
||||
// still gates it as tested/promoted. Skip if the version is the currently-
|
||||
// promoted one — that's authoritative, not a failure signal.
|
||||
const stillOk = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM betas
|
||||
WHERE script_version_id = ?
|
||||
AND id <> ?
|
||||
AND state IN ('ready-promote','promoted')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(beta.script_version_id, betaId);
|
||||
const isCurrent = script.current_version_id === beta.script_version_id;
|
||||
if (!stillOk && !isCurrent) {
|
||||
db()
|
||||
.prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`)
|
||||
.run(beta.script_version_id);
|
||||
}
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
|
||||
|
||||
return { betaId, restored, dropped, warnings };
|
||||
@@ -342,6 +362,12 @@ export interface PromoteResult {
|
||||
betaId: number;
|
||||
landed: string[]; // servers we wrote the promoted target to
|
||||
versionId: number;
|
||||
deploymentId: string;
|
||||
}
|
||||
|
||||
function promoteDeploymentId(betaId: number): string {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
return `dep-${stamp}-promote-${betaId}`;
|
||||
}
|
||||
|
||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
@@ -355,15 +381,86 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
|
||||
const betaServers = listBetaServers(betaId).map((r) => r.server);
|
||||
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
|
||||
// For scoped scripts, promotion applies to every matching server (whether
|
||||
// or not they were in the beta). For unscoped, just the beta servers.
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const allTargets = Array.from(new Set([...betaServers, ...scopeServers]));
|
||||
const allTargets = Array.from(new Set([...betaServerSet, ...scopeServers]));
|
||||
|
||||
// Snapshot what's on-disk at every target BEFORE we write, so the promote
|
||||
// deployment can be rolled back later. For beta servers the real "before"
|
||||
// state is the .OLD file (or nothing), not the current -beta.gsc.
|
||||
interface Snapshot {
|
||||
server: string;
|
||||
priorVersionId: number | null;
|
||||
priorSha: string | null;
|
||||
priorSize: number | null;
|
||||
priorContent: Buffer | null;
|
||||
hadFile: boolean;
|
||||
}
|
||||
const snapshots: Snapshot[] = [];
|
||||
for (const name of allTargets) {
|
||||
const dest = destOf(name);
|
||||
if (betaServerSet.has(name)) {
|
||||
// Prior state is captured in the beta_servers row + .OLD file.
|
||||
const bs = db()
|
||||
.prepare(`SELECT had_original, prior_version_id FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, name) as { had_original: number; prior_version_id: number | null } | undefined;
|
||||
if (bs?.had_original) {
|
||||
const backupPath = join(dest, ofile);
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
let priorSize: number | null = null;
|
||||
if (existsSync(backupPath)) {
|
||||
priorContent = await readFile(backupPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
priorSize = priorContent.byteLength;
|
||||
} else if (bs.prior_version_id) {
|
||||
const pv = getVersion(bs.prior_version_id);
|
||||
if (pv) {
|
||||
priorContent = pv.content;
|
||||
priorSha = pv.sha256;
|
||||
priorSize = pv.size;
|
||||
}
|
||||
}
|
||||
snapshots.push({
|
||||
server: name,
|
||||
priorVersionId: bs.prior_version_id,
|
||||
priorSha,
|
||||
priorSize,
|
||||
priorContent,
|
||||
hadFile: true
|
||||
});
|
||||
} else {
|
||||
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
|
||||
}
|
||||
} else {
|
||||
// Non-beta matching-scope server — the current on-disk target is
|
||||
// its "before".
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
const inv = getServerScript(name, script.target_rel_path);
|
||||
snapshots.push({
|
||||
server: name,
|
||||
priorVersionId: inv?.script_version_id ?? null,
|
||||
priorSha: sha256Buffer(priorContent),
|
||||
priorSize: priorContent.byteLength,
|
||||
priorContent,
|
||||
hadFile: true
|
||||
});
|
||||
} else {
|
||||
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now write files. Failures are per-server and don't abort the promote —
|
||||
// the deployment record captures who succeeded and who didn't.
|
||||
const landed: string[] = [];
|
||||
const failures = new Map<string, string>();
|
||||
for (const name of allTargets) {
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
@@ -381,14 +478,74 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
removeScript(name, ofile);
|
||||
}
|
||||
await writeFile(targetPath, version.content);
|
||||
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `promote:${betaId}`);
|
||||
landed.push(name);
|
||||
} catch (err) {
|
||||
// Best-effort continue; error will surface via the beta detail UI.
|
||||
const msg = (err as Error).message;
|
||||
failures.set(name, msg);
|
||||
console.error(`[promote] ${name} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the deployment record. Only landed servers get deployment_files
|
||||
// rows — a failed write leaves the prior file on disk, so recording it as
|
||||
// if it changed would corrupt any future rollback.
|
||||
const depId = promoteDeploymentId(betaId);
|
||||
const startedAt = nowIso();
|
||||
const priorScriptVersionId = script.current_version_id;
|
||||
|
||||
const insertDep = db().prepare(
|
||||
`INSERT INTO deployments
|
||||
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
|
||||
VALUES (?, 'promote', ?, 'ui', ?, ?, ?, ?)`
|
||||
);
|
||||
const insertDepServer = db().prepare(
|
||||
`INSERT INTO deployment_servers
|
||||
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, ?, 0, ?)`
|
||||
);
|
||||
const insertDepFile = db().prepare(
|
||||
`INSERT INTO deployment_files
|
||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
db().transaction(() => {
|
||||
insertDep.run(depId, startedAt, script.id, version.id, priorScriptVersionId, betaId);
|
||||
for (const snap of snapshots) {
|
||||
if (failures.has(snap.server)) {
|
||||
// Record the failure but no file row — nothing changed on disk.
|
||||
insertDepServer.run(depId, snap.server, 0, 'failed', null, failures.get(snap.server)!);
|
||||
continue;
|
||||
}
|
||||
const verb = snap.hadFile ? 'updated' : 'added';
|
||||
insertDepFile.run(
|
||||
depId,
|
||||
snap.server,
|
||||
verb,
|
||||
script.target_rel_path,
|
||||
snap.priorSha,
|
||||
version.sha256,
|
||||
snap.priorContent,
|
||||
version.content
|
||||
);
|
||||
|
||||
// Beta servers are already at the new bytes AND already restarted
|
||||
// during the beta's own state machine. Non-beta matching servers
|
||||
// got the file just now and still need to drain-and-restart, which
|
||||
// the deployment sweep handles.
|
||||
if (betaServerSet.has(snap.server)) {
|
||||
const bsRestartedAt = db()
|
||||
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, snap.server) as { restarted_at: string | null } | undefined;
|
||||
insertDepServer.run(depId, snap.server, 1, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
|
||||
} else {
|
||||
insertDepServer.run(depId, snap.server, 1, 'pending', null, null);
|
||||
}
|
||||
|
||||
recordScript(snap.server, script.target_rel_path, version.id, version.sha256, version.size, `promote:${depId}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// Update the script's canonical version and close out the beta + wipe archive.
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
||||
@@ -397,8 +554,14 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
emit({
|
||||
type: 'deployment-opened',
|
||||
deploymentId: depId,
|
||||
action: 'promote',
|
||||
servers: landed
|
||||
});
|
||||
|
||||
return { betaId, landed, versionId: version.id };
|
||||
return { betaId, landed, versionId: version.id, deploymentId: depId };
|
||||
}
|
||||
|
||||
// ---------- helpers used by API layer ----------
|
||||
|
||||
Reference in New Issue
Block a user