feat: add independent demo seed sync command

This commit is contained in:
tianma-if committed 2026-07-24 11:30:08 +08:00
1 parent 75950c856e
commit cd62151b11
4 files changed
+251 -2

No files matched your search

@@ -1,4 +1,4 @@
name: Deploy Demo on Release
name: Deploy Demo
on:
release:
@@ -39,7 +39,7 @@ jobs:
EDGE_EVER_DEPLOYMENT_METHOD: "github_actions"
steps:
- name: Checkout release
- name: Checkout Demo source
uses: actions/checkout@v5
with:
ref: ${{ github.event.release.tag_name || github.ref }}
+4
View File
@@ -78,6 +78,10 @@ bun run build:android:apk:local
- 仅在用户明确指定远程实例并要求连接时,使用 `EDGE_EVER_INSTANCE=<实例名> bun run dev:remote`;私有配置以 `.env.local` 为准,不得硬编码实例名。
- 仅在用户明确要求只启动前端时使用 `bun run dev:web`。
## Demo 示例笔记同步
修改 Demo 示例笔记后,使用 `bun run demo:sync` 将已推送的 `main` 部署到公开 Demo,并用最新种子重置示例数据。该命令独立于正式 Release 流程,不创建版本号、标签、Release 或 APK;执行前必须确保工作区干净且 `main` 与 `origin/main` 一致。命令会删除公开 Demo 中的临时内容。
## 组件复用与造轮子约束
UI 功能应尽量复用 `shadcn/ui` 等现有 UI 组件。在实现其他功能时,也应优先采用成熟、稳定的开源组件或库,绝对禁止在没有充分必要性的前提下自行从零造轮子。
+1
View File
@@ -49,6 +49,7 @@
"deploy:worker": "bun scripts/run-wrangler.mjs deploy",
"deploy:cloudflare-builds": "bun run deploy:ci",
"deploy:verify": "bun scripts/verify-deployment.mjs",
"demo:sync": "bun scripts/sync-demo.mjs",
"deploy:builds:setup": "bun scripts/cloudflare-workers-builds.mjs setup",
"deploy:site": "bun run build:site && wrangler pages deploy apps/site/dist --project-name=edgeever-official"
},
+244
View File
@@ -0,0 +1,244 @@
import { spawnSync } from "node:child_process";
const REPOSITORY = "tianma-if/edgeever";
const WORKFLOW = "deploy-demo.yml";
const DEFAULT_DEMO_URL = "https://demo.edgeever.org";
const DEFAULT_DEMO_USERNAME = "ee-demo";
const DEFAULT_DEMO_PASSWORD = "demo#dZ6Q29Zjfor%";
const RUN_DISCOVERY_ATTEMPTS = 30;
const RUN_DISCOVERY_INTERVAL_MS = 2_000;
const usage = `Usage:
bun run demo:sync
bun run demo:sync -- --dry-run
Deploys the pushed main commit through the Demo GitHub Actions workflow, resets
the public Demo workspace to that commit's seed data, and verifies the overview
memo. It does not create a tag or Release. The command requires an authenticated
GitHub CLI.
Optional environment overrides:
EDGE_EVER_DEMO_URL
EDGE_EVER_DEMO_USERNAME
EDGE_EVER_DEMO_PASSWORD`;
export const parseSyncDemoArgs = (args) => {
let dryRun = false;
let help = false;
for (const arg of args) {
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg === "--help" || arg === "-h") {
help = true;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { dryRun, help };
};
const run = (executable, args, options = {}) => {
const result = spawnSync(executable, args, {
encoding: "utf8",
env: process.env,
stdio: options.capture ? "pipe" : "inherit",
});
if (result.status !== 0) {
if (options.capture) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
}
throw new Error(`${executable} exited with status ${result.status ?? 1}.`);
}
return options.capture ? result.stdout.trim() : "";
};
const runJson = (executable, args) => {
const output = run(executable, args, { capture: true });
try {
return JSON.parse(output);
} catch {
throw new Error(`Unable to parse JSON output from ${executable} ${args.join(" ")}.`);
}
};
const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
const assertRepositoryState = () => {
const branch = run("git", ["branch", "--show-current"], { capture: true });
if (branch !== "main") {
throw new Error(`Demo sync must run on main; current branch is ${branch || "(detached)"}.`);
}
const status = run("git", ["status", "--porcelain"], { capture: true });
if (status) {
throw new Error("Demo sync requires a clean working tree. Commit or stash local changes first.");
}
run("git", ["fetch", "origin", "main"]);
const headSha = run("git", ["rev-parse", "HEAD"], { capture: true });
const remoteSha = run("git", ["rev-parse", "origin/main"], { capture: true });
if (headSha !== remoteSha) {
throw new Error(
`Local main (${headSha.slice(0, 12)}) does not match origin/main (${remoteSha.slice(0, 12)}). Push or update main first.`,
);
}
run("gh", ["auth", "status"]);
const repository = runJson("gh", ["repo", "view", REPOSITORY, "--json", "nameWithOwner"]);
if (repository.nameWithOwner !== REPOSITORY) {
throw new Error(`GitHub repository is unavailable: ${REPOSITORY}.`);
}
return headSha;
};
const listWorkflowRuns = () =>
runJson("gh", [
"run",
"list",
"--repo",
REPOSITORY,
"--workflow",
WORKFLOW,
"--event",
"workflow_dispatch",
"--branch",
"main",
"--limit",
"20",
"--json",
"databaseId,headSha,status,conclusion,url,createdAt",
]);
const dispatchDeployment = async (headSha) => {
const previousRunIds = new Set(listWorkflowRuns().map((workflowRun) => workflowRun.databaseId));
run("gh", [
"workflow",
"run",
WORKFLOW,
"--repo",
REPOSITORY,
"--ref",
"main",
]);
for (let attempt = 0; attempt < RUN_DISCOVERY_ATTEMPTS; attempt += 1) {
const workflowRun = listWorkflowRuns().find(
(candidate) => candidate.headSha === headSha && !previousRunIds.has(candidate.databaseId),
);
if (workflowRun) {
return workflowRun;
}
await sleep(RUN_DISCOVERY_INTERVAL_MS);
}
throw new Error("Timed out while waiting for the Demo deployment workflow run to appear.");
};
const requestJson = async (url, options = {}) => {
const response = await fetch(url, options);
const body = await response.json().catch(() => null);
if (!response.ok) {
const message = body?.error?.message || body?.message || response.statusText;
throw new Error(`Demo API request failed (${response.status}): ${message}`);
}
return body;
};
const resetAndVerifyDemo = async () => {
const demoUrl = (process.env.EDGE_EVER_DEMO_URL || DEFAULT_DEMO_URL).replace(/\/+$/, "");
const username = process.env.EDGE_EVER_DEMO_USERNAME || DEFAULT_DEMO_USERNAME;
const password = process.env.EDGE_EVER_DEMO_PASSWORD || DEFAULT_DEMO_PASSWORD;
const session = await requestJson(`${demoUrl}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!session?.sessionToken) {
throw new Error("Demo login succeeded without returning a session token.");
}
const authorization = { Authorization: `Bearer ${session.sessionToken}` };
await requestJson(`${demoUrl}/api/v1/demo/reset`, {
method: "POST",
headers: authorization,
});
const response = await requestJson(`${demoUrl}/api/v1/memos/memo_demo_overview`, {
headers: authorization,
});
const memo = response.memo ?? response;
if (memo.id !== "memo_demo_overview" || !memo.title || !memo.contentMarkdown) {
throw new Error("Demo overview memo verification returned incomplete data.");
}
return {
demoUrl,
title: memo.title,
updatedAt: memo.updatedAt,
};
};
const main = async () => {
let options;
try {
options = parseSyncDemoArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error(usage);
process.exit(1);
}
if (options.help) {
console.log(usage);
return;
}
if (options.dryRun) {
console.log("[dry-run] validate clean, pushed main and GitHub CLI authentication");
console.log(`[dry-run] dispatch ${WORKFLOW} on ${REPOSITORY}@main and wait for success`);
console.log("[dry-run] reset the public Demo workspace from the deployed seed");
console.log("[dry-run] verify memo_demo_overview through the Demo API");
return;
}
console.log("[1/4] Validating repository and GitHub access...");
const headSha = assertRepositoryState();
console.log(`[ok] main is clean and pushed at ${headSha.slice(0, 12)}`);
console.log("[2/4] Dispatching the Demo deployment workflow...");
const workflowRun = await dispatchDeployment(headSha);
console.log(`[ok] workflow run: ${workflowRun.url}`);
console.log("[3/4] Waiting for the Demo deployment...");
run("gh", [
"run",
"watch",
String(workflowRun.databaseId),
"--repo",
REPOSITORY,
"--exit-status",
]);
console.log("[4/4] Resetting and verifying Demo seed data...");
const verification = await resetAndVerifyDemo();
console.log(`[ok] ${verification.title}`);
console.log(`[ok] updated at ${verification.updatedAt}`);
console.log(`Demo seed synchronized: ${verification.demoUrl}`);
};
if (import.meta.main) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}