feat: add dev update channel

This commit is contained in:
Peter Steinberger
2026-01-20 13:33:31 +00:00
parent cc24ede586
commit 4ebf55f1db
14 changed files with 378 additions and 123 deletions

View File

@@ -0,0 +1,26 @@
export type UpdateChannel = "stable" | "beta" | "dev";
export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable";
export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev";
export const DEV_BRANCH = "main";
export function normalizeUpdateChannel(value?: string | null): UpdateChannel | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "stable" || normalized === "beta" || normalized === "dev") return normalized;
return null;
}
export function channelToNpmTag(channel: UpdateChannel): string {
if (channel === "beta") return "beta";
if (channel === "dev") return "dev";
return "latest";
}
export function isBetaTag(tag: string): boolean {
return tag.toLowerCase().includes("-beta");
}
export function isStableTag(tag: string): boolean {
return !isBetaTag(tag);
}

View File

@@ -43,6 +43,7 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
});
@@ -67,11 +68,12 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main",
},
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
});

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
import { trimLogTail } from "./restart-sentinel.js";
export type UpdateStepResult = {
@@ -53,6 +54,7 @@ type UpdateRunnerOptions = {
cwd?: string;
argv1?: string;
tag?: string;
channel?: UpdateChannel;
timeoutMs?: number;
runCommand?: CommandRunner;
progress?: UpdateStepProgress;
@@ -60,7 +62,6 @@ type UpdateRunnerOptions = {
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) {
@@ -106,6 +107,46 @@ async function readPackageVersion(root: string) {
}
}
async function readBranchName(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
): Promise<string | null> {
const res = await runCommand(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], {
timeoutMs,
}).catch(() => null);
if (!res || res.code !== 0) return null;
const branch = res.stdout.trim();
return branch || null;
}
async function listGitTags(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
pattern = "v*",
): Promise<string[]> {
const res = await runCommand(["git", "-C", root, "tag", "--list", pattern, "--sort=-v:refname"], {
timeoutMs,
}).catch(() => null);
if (!res || res.code !== 0) return [];
return res.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function resolveChannelTag(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
channel: Exclude<UpdateChannel, "dev">,
): Promise<string | null> {
const tags = await listGitTags(runCommand, root, timeoutMs);
const predicate = channel === "beta" ? isBetaTag : isStableTag;
return tags.find((tag) => predicate(tag)) ?? null;
}
async function resolveGitRoot(
runCommand: CommandRunner,
candidates: string[],
@@ -281,9 +322,6 @@ function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
return ["npm", "i", "-g", spec];
}
// Total number of visible steps in a successful git update flow
const GIT_UPDATE_TOTAL_STEPS = 9;
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
const startedAt = Date.now();
const runCommand =
@@ -298,6 +336,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const candidates = buildStartDirs(opts);
let stepIndex = 0;
let gitTotalSteps = 0;
const step = (
name: string,
@@ -316,7 +355,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
env,
progress,
stepIndex: currentIndex,
totalSteps: GIT_UPDATE_TOTAL_STEPS,
totalSteps: gitTotalSteps,
};
};
@@ -346,6 +385,10 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
});
const beforeSha = beforeShaResult.stdout.trim() || null;
const beforeVersion = await readPackageVersion(gitRoot);
const channel: UpdateChannel = opts.channel ?? "dev";
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8;
const statusCheck = await runStep(
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
@@ -365,58 +408,135 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
};
}
const upstreamStep = await runStep(
step(
"upstream check",
["git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
gitRoot,
),
);
steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "no-upstream",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
if (channel === "dev") {
if (needsCheckoutMain) {
const checkoutStep = await runStep(
step(
`git checkout ${DEV_BRANCH}`,
["git", "-C", gitRoot, "checkout", DEV_BRANCH],
gitRoot,
),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "checkout-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
}
const fetchStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot),
);
steps.push(fetchStep);
const upstreamStep = await runStep(
step(
"upstream check",
[
"git",
"-C",
gitRoot,
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
gitRoot,
),
);
steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "no-upstream",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
cwd: gitRoot,
timeoutMs,
});
steps.push({
name: "git rebase --abort",
command: "git rebase --abort",
cwd: gitRoot,
durationMs: 0,
exitCode: abortResult.code,
stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS),
});
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "rebase-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
const fetchStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
);
steps.push(fetchStep);
const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
cwd: gitRoot,
timeoutMs,
});
steps.push({
name: "git rebase --abort",
command: "git rebase --abort",
cwd: gitRoot,
durationMs: 0,
exitCode: abortResult.code,
stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS),
});
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "rebase-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
} else {
const fetchStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
);
steps.push(fetchStep);
if (fetchStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "fetch-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const tag = await resolveChannelTag(runCommand, gitRoot, timeoutMs, channel);
if (!tag) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "no-release-tag",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const checkoutStep = await runStep(
step(`git checkout ${tag}`, ["git", "-C", gitRoot, "checkout", "--detach", tag], gitRoot),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "checkout-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
}
const manager = await detectPackageManager(gitRoot);

View File

@@ -5,6 +5,11 @@ import type { loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveClawdbotPackageRoot } from "./clawdbot-root.js";
import { compareSemverStrings, fetchNpmTagVersion, checkUpdateStatus } from "./update-check.js";
import {
channelToNpmTag,
normalizeUpdateChannel,
DEFAULT_PACKAGE_CHANNEL,
} from "./update-channels.js";
import { VERSION } from "../version.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -17,17 +22,6 @@ type UpdateCheckState = {
const UPDATE_CHECK_FILENAME = "update-check.json";
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
function normalizeChannel(value?: string | null): "stable" | "beta" | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "stable" || normalized === "beta") return normalized;
return null;
}
function channelToTag(channel: "stable" | "beta"): string {
return channel === "beta" ? "beta" : "latest";
}
function shouldSkipCheck(allowInTests: boolean): boolean {
if (allowInTests) return false;
if (process.env.VITEST || process.env.NODE_ENV === "test") return true;
@@ -89,8 +83,8 @@ export async function runGatewayUpdateCheck(params: {
return;
}
const channel = normalizeChannel(params.cfg.update?.channel) ?? "stable";
const tag = channelToTag(channel);
const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
const tag = channelToNpmTag(channel);
const tagStatus = await fetchNpmTagVersion({ tag, timeoutMs: 2500 });
if (!tagStatus.version) {
await writeState(statePath, nextState);