Commit 10c7988e authored by xuchentao's avatar xuchentao

feat: simplify version preview and release workflow

parent 2d6e5007
......@@ -35,11 +35,24 @@ export class BuildManager {
}
async publishPreview(siteId: string, distPath: string): Promise<string> {
return this.publishTo(path.join(runtimePaths.previews, siteId), distPath);
return this.publishAtomically(path.join(runtimePaths.previews, siteId), distPath);
}
async publishProduction(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath);
}
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId);
const indexPath = path.join(target, "index.html");
if (await stat(indexPath).then((value) => value.isFile()).catch(() => false)) return target;
const safeName = siteName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
await mkdir(target, { recursive: true });
await writeFile(indexPath, `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width"><title>${safeName}|尚未上线</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;color:#24242f;background:#f7f7fb;font-family:system-ui,"PingFang SC",sans-serif}.card{max-width:420px;padding:42px;text-align:center;background:white;border:1px solid #e8e5f1;border-radius:24px;box-shadow:0 22px 60px rgba(36,16,95,.09)}span{display:grid;place-items:center;width:48px;height:48px;margin:auto;color:white;background:linear-gradient(135deg,#7028ff,#536cff);border-radius:15px;font-weight:800}h1{margin:20px 0 10px;font-size:24px}p{margin:0;color:#777985;font-size:14px;line-height:1.8}</style></head><body><main class="card"><span>W</span><h1>${safeName}</h1><p>该官网尚未上线。<br>请先在测试环境确认效果后发布。</p></main></body></html>`, "utf8");
return target;
}
private async publishAtomically(target: string, distPath: string): Promise<string> {
const suffix = crypto.randomBytes(4).toString("hex");
const staging = target + ".next-" + suffix;
const previous = target + ".previous-" + suffix;
......@@ -57,11 +70,4 @@ export class BuildManager {
await rm(previous, { recursive: true, force: true });
return target;
}
private async publishTo(target: string, distPath: string): Promise<string> {
await rm(target, { recursive: true, force: true });
await mkdir(path.dirname(target), { recursive: true });
await cp(distPath, target, { recursive: true });
return target;
}
}
......@@ -24,6 +24,12 @@ export class GitManager {
return result.stdout.trim();
}
async previousCommit(projectPath: string, commit = "HEAD"): Promise<string | undefined> {
await this.assertCommit(projectPath, commit);
const result = await execa("git", ["rev-list", "--parents", "-n", "1", commit], { cwd: projectPath });
return result.stdout.trim().split(/\s+/)[1];
}
async history(projectPath: string): Promise<GitHistoryItem[]> {
const current = await this.currentCommit(projectPath);
const result = await execa("git", ["log", "--date=iso-strict", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI%x1e", "-30"], { cwd: projectPath });
......
......@@ -14,7 +14,7 @@ export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000),
});
export const rollbackSchema = z.object({
export const versionCommitSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/),
});
......
......@@ -8,7 +8,7 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, rollbackSchema } from "./schemas.js";
import { chatSchema, createSiteSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js";
......@@ -33,11 +33,12 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/chat", async (reque
const body = chatSchema.parse(request.body); return siteAgent.execute(request.params.siteId, body.message);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (request) => versions.rebuild(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => {
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/rollback", async (request) => {
const body = rollbackSchema.parse(request.body); return versions.rollback(request.params.siteId, body.commit);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
app.setErrorHandler((error, _request, reply) => {
const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT";
......@@ -54,6 +55,7 @@ await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId));
await builds.ensureProductionPlaceholder(site.siteId, site.name);
if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error));
continue;
......
......@@ -48,7 +48,8 @@ export class CreateSiteService {
await this.sites.update(siteId, { currentCommit: commit });
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, previewPort);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, lastError: undefined });
await this.builds.ensureProductionPlaceholder(siteId, input.name);
return await this.sites.update(siteId, { status: "ready", currentCommit: commit, previewCommit: commit, lastError: undefined });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: message });
......
......@@ -19,6 +19,9 @@ export class SiteAgentService {
async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前测试环境正在预览历史版本,请先切换到最近保存版本再继续修改");
}
const projectPath = this.sites.getProjectPath(siteId);
const taskId = "task_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
......@@ -49,11 +52,14 @@ export class SiteAgentService {
const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined });
await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, environmentVersion: 3, lastError: undefined,
});
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: "failed", lastError: details });
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
} finally {
await this.git.removeWorktree(projectPath, workspace);
......
......@@ -30,7 +30,8 @@ export class SiteRepository {
return {
...stored,
previewUrl: getPublicPreviewUrl(siteId),
productionUrl: stored.publishedCommit ? getPublicProductionUrl(siteId) : undefined,
previewCommit: stored.previewCommit || stored.currentCommit,
productionUrl: getPublicProductionUrl(siteId),
publishStatus: stored.publishStatus || (stored.publishedCommit ? "published" : "unpublished"),
};
}
......
import crypto from "node:crypto";
import path from "node:path";
import type { PublishResult } from "@webagent/shared";
import type { PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
......@@ -15,42 +15,53 @@ export class SiteVersionService {
) {}
async rebuild(siteId: string): Promise<{ previewUrl: string }> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
const site = await this.sites.get(siteId);
let currentCommit = await this.git.currentCommit(project);
let previewCommit = site.previewCommit || site.currentCommit || currentCommit;
if (await ensureEnvironmentConfig(project)) {
await this.git.commit(project, "System: support preview and production paths");
currentCommit = await this.git.commit(project, "System: support preview and production paths");
if (previewCommit === site.currentCommit) previewCommit = currentCommit;
}
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
const dist = await this.builds.build(project, "rebuild_" + siteId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: await this.git.currentCommit(project), environmentVersion: 3 });
await this.buildPreview(site, previewCommit, "rebuild", { currentCommit });
return { previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
}
async previewVersion(siteId: string, targetCommit: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
await this.git.assertCommit(project, targetCommit);
await this.buildPreview(site, targetCommit, "preview_version");
return { commit: targetCommit, previewUrl: site.previewUrl };
}
async rollback(siteId: string, targetCommit: string): Promise<{ commit: string; previewUrl: string }> {
async undo(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
const project = this.sites.getProjectPath(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
}
const currentCommit = await this.git.currentCommit(project);
const targetCommit = site.previousCommit || await this.git.previousCommit(project, currentCommit);
if (!targetCommit) throw new Error("当前没有可撤销的上一版本");
await this.git.assertCommit(project, targetCommit);
const taskId = "rollback_" + crypto.randomBytes(4).toString("hex");
const taskId = "undo_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
await this.git.createWorktree(project, workspace, targetCommit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
const published = await this.builds.publishPreview(siteId, dist);
const commit = await this.git.restoreAsCommit(project, targetCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, { status: "ready", currentCommit: commit, environmentVersion: 3, lastError: undefined });
await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: undefined, environmentVersion: 3, lastError: undefined,
});
return { commit, previewUrl: site.previewUrl };
} catch (error) {
await this.sites.update(siteId, { status: "failed", lastError: error instanceof Error ? error.message : String(error) });
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: error instanceof Error ? error.message : String(error) });
throw error;
} finally { await this.git.removeWorktree(project, workspace); }
}
......@@ -63,8 +74,10 @@ export class SiteVersionService {
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(siteId, { publishStatus: "publishing", lastPublishError: undefined });
try {
const commit = await this.git.currentCommit(project);
const commit = site.previewCommit || site.currentCommit;
await this.git.assertCommit(project, commit);
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicProductionUrl(siteId));
await this.builds.publishProduction(siteId, dist);
const publishedAt = new Date().toISOString();
......@@ -82,4 +95,34 @@ export class SiteVersionService {
await this.git.removeWorktree(project, workspace);
}
}
private async buildPreview(
site: SiteInfo,
commit: string,
taskPrefix: string,
metadata: Partial<SiteInfo> = {},
): Promise<void> {
const project = this.sites.getProjectPath(site.siteId);
await this.git.assertCommit(project, commit);
const taskId = taskPrefix + "_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(site.siteId, { status: "building", lastError: undefined });
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(site.siteId));
const published = await this.builds.publishPreview(site.siteId, dist);
await this.previews.start(site.siteId, published, site.previewPort);
await this.sites.update(site.siteId, {
...metadata,
status: "ready", previewCommit: commit, environmentVersion: 3, lastError: undefined,
});
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(site.siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
} finally {
await this.git.removeWorktree(project, workspace);
}
}
}
This diff is collapsed.
import type { ChatResult, CreateSiteInput, GitHistoryItem, PublishResult, SiteInfo } from "@webagent/shared";
import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
......@@ -19,7 +19,8 @@ export const api = {
createSite: (input: CreateSiteInput) => request<SiteInfo>("/api/sites", { method: "POST", body: JSON.stringify(input) }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
rollback: (siteId: string, commit: string) => request<{ commit: string; previewUrl: string }>("/api/sites/" + siteId + "/rollback", { method: "POST", body: JSON.stringify({ commit }) }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
};
This diff is collapsed.
......@@ -22,6 +22,8 @@ export interface SiteInfo {
previewUrl: string;
productionUrl?: string;
currentCommit: string;
previewCommit?: string;
previousCommit?: string;
publishedCommit?: string;
publishedAt?: string;
publishStatus: PublishStatus;
......@@ -61,3 +63,8 @@ export interface PublishResult {
commit: string;
publishedAt: string;
}
export interface PreviewVersionResult {
previewUrl: string;
commit: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment