feat: add independent demo seed sync command
This commit is contained in:
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 }}
|
||||
@@ -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 组件。在实现其他功能时,也应优先采用成熟、稳定的开源组件或库,绝对禁止在没有充分必要性的前提下自行从零造轮子。
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user