ci: deploy demo automatically

This commit is contained in:
SnapOtter
2026-07-04 13:08:24 +08:00
parent f7b9a818b5
commit 7473d2d0fc
4 changed files with 143 additions and 7 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Deploy Demo to Cloudflare Pages
on:
push:
branches: [main]
paths:
- "apps/demo/**"
- "apps/web/**"
- "packages/shared/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "tsconfig.base.json"
- ".github/actions/setup/**"
- ".github/workflows/deploy-demo.yml"
workflow_dispatch:
concurrency:
group: deploy-demo
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/setup
- name: Build Demo
run: pnpm --filter @snapotter/demo build
- name: Deploy to Cloudflare Pages
run: npx wrangler pages deploy apps/demo/dist --project-name snapotter-demo --branch main --commit-dirty=true
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+18 -7
View File
@@ -1,3 +1,7 @@
import packageJson from "../../../package.json";
const DEMO_APP_VERSION = packageJson.version;
const PERMISSIONS = [
"tools:use",
"files:own",
@@ -108,11 +112,18 @@ function json(data: unknown, status = 200): Response {
});
}
function matchRoute(url: string, method: string): Response | null {
export function matchDemoRoute(url: string, method: string): Response | null {
const path = new URL(url, "http://localhost").pathname;
if (path === "/api/v1/config/auth" && method === "GET") {
return json({ authEnabled: true, oidcEnabled: false });
return json({
authEnabled: true,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
});
}
if (path === "/api/auth/login" && method === "POST") {
@@ -145,7 +156,7 @@ function matchRoute(url: string, method: string): Response | null {
}
if (path === "/api/v1/health") {
return json({ status: "ok", version: "1.17.0" });
return json({ status: "ok", version: DEMO_APP_VERSION });
}
if (path === "/api/v1/settings" && method === "GET") {
@@ -261,14 +272,14 @@ function matchRoute(url: string, method: string): Response | null {
return null;
}
const originalFetch = window.fetch.bind(window);
export function installMocks() {
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const method = init?.method?.toUpperCase() || "GET";
const mock = matchRoute(url, method);
const mock = matchDemoRoute(url, method);
if (mock) return mock;
return originalFetch(input, init);
@@ -288,7 +299,7 @@ export function installMocks() {
send(body?: Document | XMLHttpRequestBodyInit | null) {
if (this._url.startsWith("/api/")) {
const mock = matchRoute(this._url, this._method);
const mock = matchDemoRoute(this._url, this._method);
if (mock) {
setTimeout(async () => {
const responseText = await mock.text();
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { matchDemoRoute } from "../../../apps/demo/src/mock-api.js";
const packageJson = JSON.parse(
readFileSync(path.resolve(process.cwd(), "package.json"), "utf8"),
) as { version: string };
async function readJson(response: Response): Promise<unknown> {
return response.json();
}
describe("demo mock API", () => {
it("reports the same app version as the current package build", async () => {
const response = matchDemoRoute("/api/v1/health", "GET");
expect(response?.status).toBe(200);
await expect(readJson(response as Response)).resolves.toMatchObject({
status: "ok",
version: packageJson.version,
});
});
it("returns the full auth configuration shape consumed by the real web app", async () => {
const response = matchDemoRoute("/api/v1/config/auth", "GET");
expect(response?.status).toBe(200);
await expect(readJson(response as Response)).resolves.toEqual({
authEnabled: true,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
});
});
});
@@ -0,0 +1,46 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const workflowPath = path.resolve(process.cwd(), ".github/workflows/deploy-demo.yml");
function workflowText(): string {
expect(existsSync(workflowPath)).toBe(true);
return readFileSync(workflowPath, "utf8");
}
describe("demo deployment workflow", () => {
it("deploys the demo app to the Cloudflare Pages demo project", () => {
const workflow = workflowText();
expect(workflow).toContain("name: Deploy Demo to Cloudflare Pages");
expect(workflow).toContain("pnpm --filter @snapotter/demo build");
expect(workflow).toContain(
"npx wrangler pages deploy apps/demo/dist --project-name snapotter-demo --branch main --commit-dirty=true",
);
expect(workflow).toContain("CLOUDFLARE_API_TOKEN: $" + "{{ secrets.CLOUDFLARE_API_TOKEN }}");
expect(workflow).toContain("CLOUDFLARE_ACCOUNT_ID: $" + "{{ secrets.CLOUDFLARE_ACCOUNT_ID }}");
});
it("rebuilds when the demo shell, real app UI, shared code, or build inputs change", () => {
const workflow = workflowText();
for (const pathFilter of [
'"apps/demo/**"',
'"apps/web/**"',
'"packages/shared/**"',
'"package.json"',
'"pnpm-lock.yaml"',
'"pnpm-workspace.yaml"',
'".github/actions/setup/**"',
'".github/workflows/deploy-demo.yml"',
]) {
expect(workflow).toContain(pathFilter);
}
expect(workflow).toContain("branches: [main]");
expect(workflow).toContain("workflow_dispatch:");
expect(workflow).toContain("concurrency:");
expect(workflow).toContain("group: deploy-demo");
});
});