合并 master 的预览部署控制面
保留 Spine 序列帧与 Jenkins 容器预览决策记录 合入 preview-deployer-web、preview-deployer-server 及部署脚本
This commit is contained in:
@@ -39,6 +39,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
/apps/mobile-shell/.expo-export-smoke/
|
||||
/apps/preview-deployer-web/dist/
|
||||
/apps/preview-deployer-web/node_modules/
|
||||
/server-rs/.spacetimedb/
|
||||
/server-rs/.data/
|
||||
/public/generated-animations
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Docker 预览发布</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@genarrative/preview-deployer-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run -c vitest.config.ts",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as api from './api';
|
||||
import { PreviewDeployerApp } from './PreviewDeployerApp';
|
||||
|
||||
vi.mock('./api');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getSession).mockResolvedValue({ authenticated: true });
|
||||
vi.mocked(api.createSession).mockResolvedValue({ authenticated: true });
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(undefined);
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([]);
|
||||
vi.mocked(api.createDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
});
|
||||
vi.mocked(api.uninstallDeployment).mockResolvedValue({
|
||||
id: 'preview-1',
|
||||
branch: 'master',
|
||||
status: 'uninstalling',
|
||||
health: 'pending',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:02:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires an access token before showing deployments', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.getSession).mockResolvedValue({ authenticated: false });
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('访问口令')).toBeTruthy();
|
||||
await user.type(screen.getByPlaceholderText('请输入访问口令'), 'team-token');
|
||||
await user.click(screen.getByRole('button', { name: '进入发布面板' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSession).toHaveBeenCalledWith('team-token');
|
||||
});
|
||||
expect(await screen.findByText('构建并发布一个分支')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submits a branch with an optional commit hash', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
const branchInput = await screen.findByPlaceholderText('master');
|
||||
await user.clear(branchInput);
|
||||
await user.type(branchInput, 'feature/preview');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('留空则构建分支最新提交'),
|
||||
'aa5221abc',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '开始构建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createDeployment).toHaveBeenCalledWith({
|
||||
branch: 'feature/preview',
|
||||
commitHash: 'aa5221abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('shows health and web url, then confirms uninstall', async () => {
|
||||
vi.mocked(api.listDeployments).mockResolvedValue([
|
||||
{
|
||||
id: 'preview-2',
|
||||
branch: 'feature/demo',
|
||||
resolvedCommit: '1234567890abcdef',
|
||||
status: 'running',
|
||||
health: 'healthy',
|
||||
webUrl: 'http://192.168.35.82:8400',
|
||||
createdAt: 1_787_270_400,
|
||||
updatedAt: 1_787_270_460,
|
||||
},
|
||||
]);
|
||||
render(<PreviewDeployerApp />);
|
||||
|
||||
expect(await screen.findByText('健康')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'),
|
||||
).toBe('http://192.168.35.82:8400');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '卸载' }));
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认卸载' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.uninstallDeployment).toHaveBeenCalledWith('preview-2');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createDeployment,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
listDeployments,
|
||||
uninstallDeployment,
|
||||
} from './api';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('lists deployments from the same-origin control plane', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
deployments: [
|
||||
{
|
||||
id: 'preview-1',
|
||||
branch: 'feature/demo',
|
||||
status: 'running',
|
||||
health: 'healthy',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:01:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const deployments = await listDeployments();
|
||||
|
||||
expect(deployments).toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/preview-deployer/deployments',
|
||||
expect.objectContaining({ credentials: 'same-origin' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses an http-only session without browser token persistence', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((_url: string, init: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
init.method === 'DELETE'
|
||||
? null
|
||||
: JSON.stringify({ authenticated: true }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
expect(await getSession()).toEqual({ authenticated: true });
|
||||
await createSession('team-access-token');
|
||||
await deleteSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/preview-deployer/session',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ accessToken: 'team-access-token' }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/preview-deployer/session',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('submits only branch and optional commit to fixed endpoints', async () => {
|
||||
const buildResponse = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'preview-2',
|
||||
branch: 'master',
|
||||
status: 'queued',
|
||||
health: 'pending',
|
||||
createdAt: '2026-08-15T00:00:00Z',
|
||||
updatedAt: '2026-08-15T00:00:00Z',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(buildResponse()));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await createDeployment({ branch: 'master', commitHash: 'aa5221abc' });
|
||||
await uninstallDeployment('preview/2');
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/preview-deployer/deployments',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ branch: 'master', commitHash: 'aa5221abc' }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/preview-deployer/deployments/preview%2F2/uninstall',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { CreateDeploymentInput, PreviewDeployment } from './types';
|
||||
|
||||
const API_BASE = '/api/preview-deployer';
|
||||
|
||||
export class PreviewDeployerApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'PreviewDeployerApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewDeployerSession {
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export async function getSession(signal?: AbortSignal) {
|
||||
const session = await request<PreviewDeployerSession | null>('/session', {
|
||||
signal,
|
||||
});
|
||||
return session ?? { authenticated: true };
|
||||
}
|
||||
|
||||
export function createSession(accessToken: string) {
|
||||
return request<PreviewDeployerSession>('/session', {
|
||||
method: 'POST',
|
||||
body: { accessToken },
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSession() {
|
||||
return request<void>('/session', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listDeployments(signal?: AbortSignal) {
|
||||
const payload = await request<
|
||||
PreviewDeployment[] | { deployments: PreviewDeployment[] }
|
||||
>('/deployments', { signal });
|
||||
return Array.isArray(payload) ? payload : payload.deployments;
|
||||
}
|
||||
|
||||
export function createDeployment(input: CreateDeploymentInput) {
|
||||
return request<PreviewDeployment>('/deployments', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function uninstallDeployment(deploymentId: string) {
|
||||
return request<PreviewDeployment>(
|
||||
`/deployments/${encodeURIComponent(deploymentId)}/uninstall`,
|
||||
{ method: 'POST', body: {} },
|
||||
);
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
signal: options.signal,
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, init);
|
||||
const responseText = await response.text();
|
||||
const payload = parseJson(responseText);
|
||||
if (!response.ok) {
|
||||
throw new PreviewDeployerApiError(
|
||||
readErrorMessage(payload) || `请求失败(HTTP ${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return unwrapPayload<T>(payload);
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
if (!value.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload<T>(payload: unknown): T {
|
||||
if (isRecord(payload) && 'data' in payload) {
|
||||
return payload.data as T;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function readErrorMessage(payload: unknown) {
|
||||
if (typeof payload === 'string') {
|
||||
return payload.trim();
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return '';
|
||||
}
|
||||
if (typeof payload.message === 'string') {
|
||||
return payload.message;
|
||||
}
|
||||
if (isRecord(payload.error) && typeof payload.error.message === 'string') {
|
||||
return payload.error.message;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import './styles.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { PreviewDeployerApp } from './PreviewDeployerApp';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Missing #root container');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<PreviewDeployerApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
export type DeploymentStatus =
|
||||
| 'queued'
|
||||
| 'building'
|
||||
| 'deploying'
|
||||
| 'running'
|
||||
| 'uninstalling'
|
||||
| 'stopped'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface PreviewDeployment {
|
||||
id: string;
|
||||
branch: string;
|
||||
commitHash?: string | null;
|
||||
resolvedCommit?: string | null;
|
||||
status: DeploymentStatus;
|
||||
health: DeploymentHealth;
|
||||
webUrl?: string | null;
|
||||
jenkinsBuildUrl?: string | null;
|
||||
createdAt: string | number;
|
||||
updatedAt: string | number;
|
||||
message?: string | null;
|
||||
canUninstall?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDeploymentInput {
|
||||
branch: string;
|
||||
commitHash?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { validateBranch, validateCommitHash } from './validation';
|
||||
|
||||
test('accepts normal branch names and rejects unsafe values', () => {
|
||||
expect(validateBranch('feature/preview-panel')).toBe('');
|
||||
expect(validateBranch('../master')).toBe('分支名格式不正确');
|
||||
expect(validateBranch('feature;rm')).toBe('分支名格式不正确');
|
||||
});
|
||||
|
||||
test('commit hash is optional but must be a git hash when present', () => {
|
||||
expect(validateCommitHash('')).toBe('');
|
||||
expect(validateCommitHash('a0242e35')).toBe('');
|
||||
expect(validateCommitHash('not-a-hash')).not.toBe('');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const BRANCH_PATTERN = /^[0-9A-Za-z._/-]+$/u;
|
||||
const COMMIT_PATTERN = /^[0-9a-fA-F]{7,40}$/u;
|
||||
|
||||
export function validateBranch(value: string) {
|
||||
const branch = value.trim();
|
||||
if (!branch) {
|
||||
return '请输入分支名';
|
||||
}
|
||||
if (
|
||||
branch.length > 200 ||
|
||||
!BRANCH_PATTERN.test(branch) ||
|
||||
branch.startsWith('/') ||
|
||||
branch.endsWith('/') ||
|
||||
branch.includes('..')
|
||||
) {
|
||||
return '分支名格式不正确';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function validateCommitHash(value: string) {
|
||||
const commitHash = value.trim();
|
||||
if (commitHash && !COMMIT_PATTERN.test(commitHash)) {
|
||||
return 'Commit Hash 需为 7 到 40 位十六进制字符';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": false,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
const appRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = {
|
||||
...loadEnv(mode, repoRoot, ''),
|
||||
...loadEnv(mode, appRoot, ''),
|
||||
};
|
||||
const apiTarget = env.PREVIEW_DEPLOYER_API_TARGET ?? 'http://127.0.0.1:8410';
|
||||
|
||||
return {
|
||||
root: appRoot,
|
||||
envDir: repoRoot,
|
||||
base: env.PREVIEW_DEPLOYER_WEB_BASE ?? '/build/',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/preview-deployer': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -118,7 +118,7 @@ services:
|
||||
soft: 4096
|
||||
hard: 4096
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# 仅部署在本机内网 HTTP 入口 http://192.168.35.82/build/。
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_BIND=127.0.0.1:8410
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_BASE_URL=http://127.0.0.1:8080/jenkins/
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ACCESS_TOKEN=<至少24字符的控制面访问口令>
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_WEB_HOST=192.168.35.82
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web
|
||||
GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false
|
||||
RUST_LOG=info,tower_http=info
|
||||
@@ -0,0 +1,35 @@
|
||||
location = /build {
|
||||
return 301 /build/;
|
||||
}
|
||||
|
||||
location ^~ /build/ {
|
||||
proxy_pass http://127.0.0.1:8410;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
|
||||
location ^~ /api/preview-deployer/ {
|
||||
proxy_pass http://127.0.0.1:8410;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Genarrative Jenkins Preview Deployer
|
||||
After=network-online.target jenkins.service
|
||||
Wants=network-online.target
|
||||
Requires=jenkins.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=jenkins
|
||||
Group=jenkins
|
||||
WorkingDirectory=/opt/genarrative/preview-deployer
|
||||
EnvironmentFile=/etc/genarrative/preview-deployer.env
|
||||
ExecStart=/opt/genarrative/preview-deployer/preview-deployer-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=30
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/genarrative/preview-deployer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -46,6 +46,7 @@
|
||||
### 后台、宿主壳与运维
|
||||
|
||||
- [Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)
|
||||
- [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)
|
||||
- [后台多账号与 Tab 访问权限](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
|
||||
- [宿主壳能力统一协议](./【前端架构】宿主壳能力统一协议-2026-06-17.md)
|
||||
- [Expo React Native 与 Tauri 宿主壳方案](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md)
|
||||
|
||||
@@ -14170,3 +14170,12 @@
|
||||
- merge master 后保留角色图层浮动工具栏和右键菜单中的 `生成动画` 快捷入口,统一进入同一个角色动作 dialog;`character-animation` 结果不支持快速编辑,仍保留 `改造 / 去背景 / 拆帧 / 下载`。快速编辑入口与提交门禁使用统一正向白名单,未知媒体或素材类型默认拒绝。
|
||||
- Spine 序列帧工具栏的“去背景”暂定为纯算法绿幕扣除:只读取正式序列帧 objectKey,使用本地 `screen-color-keying` 处理约定的 `#00FF00` 绿幕,不调用 BgFilter、阿里云或其它模型;普通图片 `/api/editor/images/background-removals` 的通用去背景链路不受影响。
|
||||
- `/api/external/v1` 不开放上述内部字段或直接转换路由,不修改 OpenAPI;本次不改 SpacetimeDB 表结构、迁移或生成 bindings。
|
||||
|
||||
## 2026-08-15 Jenkins 容器预览部署使用独立控制面
|
||||
|
||||
- 决策:多人内网容器预览不把操作表单塞进 Jenkins 页面,也不让 SPA 直接操作 Docker。独立 `preview-deployer` SPA 通过同源 Axum 代理触发固定 `shared/Genarrative-Preview-Deployer` Job;浏览器只持有控制面 HttpOnly 会话,Jenkins service account 和 API Token 只存在服务端环境。
|
||||
- 部署入口只使用内网 `http://192.168.35.82/build/`,不配置公网域名;预览 Web 端口固定为 `8400..8499`,卸载后立即释放租约,运行状态由页面刷新时的实时 Web 探针更新。
|
||||
- Jenkins 的 Compose 编排、Dockerfile 入口和执行脚本固定取自受保护的 master 控制器 checkout,目标分支只作为应用源码构建上下文;控制 Job 只授予受信任开发者和专用服务账号。
|
||||
- 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。
|
||||
- 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。
|
||||
- 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Jenkins 容器预览部署控制面技术方案
|
||||
|
||||
日期:`2026-08-15`
|
||||
|
||||
## 目标
|
||||
|
||||
为内网同事提供独立 SPA,用于填写源码分支和可选 commit,触发 Jenkins 构建并发布完整 Docker Compose 预览栈。页面持续刷新排队、构建、发布和卸载状态,成功后展示容器健康状态与 Web 内网地址。
|
||||
|
||||
控制面不替代 Jenkins:Jenkins 继续负责源码检出、全量镜像构建、SpacetimeDB 模块发布、容器启动、健康验证、卸载和构建日志。浏览器不得持有 Jenkins 用户名、API Token、Crumb 或 Docker 权限。
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
同事浏览器
|
||||
-> preview-deployer SPA
|
||||
-> preview-deployer-server 同源 API
|
||||
-> 固定 Jenkins Job shared/Genarrative-Preview-Deployer
|
||||
-> 独立 Compose project + 端口租约 + preview-result.json
|
||||
```
|
||||
|
||||
- SPA 只通过内网 `http://192.168.35.82/build/` 提供页面,API 使用同源 `/api/preview-deployer/`;不配置公网域名,不复用后台管理员 Token 或业务 API 登录态。
|
||||
- `preview-deployer-server` 只代理固定 Jenkins Job,Jenkins 凭据只从服务端环境变量读取,不进入前端 bundle、JSON 响应或日志。
|
||||
- Jenkins Job 使用仓库现有 `deploy/container/` 全量容器资产,并通过独立 Compose project 支持多个分支实例并存;为保护端口和共享 Docker 状态,构建动作在首版中串行排队。
|
||||
- 页面只读取控制面归一化后的状态,不解析 Jenkins console,也不直连 Jenkins API。
|
||||
|
||||
## 部署身份与端口
|
||||
|
||||
`deploymentId` 由规范化分支名与分支名摘要确定,同一分支稳定得到同一 ID;commit 不进入 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。
|
||||
|
||||
Web 端口池固定为 `8400..8499`:
|
||||
|
||||
1. Jenkins 在全局文件锁内读取受控状态目录。
|
||||
2. 已登记分支复用原端口;新分支选择状态表、宿主监听和 Docker 映射均未占用的端口。
|
||||
3. Jenkins 为实例使用独立 Compose project,例如 `genarrative-preview-a81f39c2d43e76ab`。
|
||||
4. 端口租约必须在容器完成真实绑定前持续受锁保护;失败构建不得把端口分配给第二个实例。
|
||||
5. 卸载成功后删除该实例的 Compose 栈、受控状态和端口租约。
|
||||
|
||||
同一分支重新构建时先完成新镜像构建,再停止旧 Compose 栈并启动新版本;源码或镜像构建失败不会提前删除原有健康实例。容器切换后的失败仍会在页面明确显示,不伪装成旧版本继续运行。
|
||||
|
||||
页面每次刷新运行中实例时,控制服务直接探测受控 Web URL;后续容器异常会将健康状态更新为 `unhealthy`,不会永久沿用 Jenkins 部署完成时的快照。
|
||||
|
||||
目标分支只提供应用构建上下文。Compose 编排文件、Dockerfile 入口和 Jenkins 执行脚本固定取自受保护的 `master` 控制器 checkout,避免普通分支替换编排文件挂载宿主路径或启用特权容器。该 Job 仍只应开放给受信任开发者,并建议在隔离构建节点运行。
|
||||
|
||||
SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:<webPort>`。
|
||||
|
||||
## Jenkins 参数与产物
|
||||
|
||||
固定 Job:`shared/Genarrative-Preview-Deployer`。
|
||||
|
||||
参数:
|
||||
|
||||
- `ACTION`:`DEPLOY`、`UNINSTALL` 或 `STATUS`。
|
||||
- `SOURCE_BRANCH`:部署时必填。
|
||||
- `COMMIT_HASH`:部署时可选,7 到 40 位十六进制。
|
||||
- `DEPLOYMENT_ID`:卸载和状态查询时必填。
|
||||
|
||||
分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。
|
||||
|
||||
每次运行归档 `preview-result.json`,至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"deploymentId": "feature-login-a81f39c2",
|
||||
"branch": "feature/login",
|
||||
"requestedCommit": "",
|
||||
"resolvedCommit": "0123456789abcdef",
|
||||
"status": "running",
|
||||
"health": "healthy",
|
||||
"webPort": 8403,
|
||||
"webUrl": "http://192.168.35.82:8403",
|
||||
"composeProject": "genarrative-preview-feature-login-a81f39c2",
|
||||
"updatedAt": "2026-08-15T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
控制面只读取固定 artifact 路径和受限字段;不得把完整 console 内容返回浏览器。
|
||||
|
||||
## 控制面 API
|
||||
|
||||
- `POST /api/preview-deployer/session`:使用控制面访问口令建立 `HttpOnly + SameSite=Strict` 会话。
|
||||
- `GET /api/preview-deployer/session`:查询当前会话状态。
|
||||
- `DELETE /api/preview-deployer/session`:退出。
|
||||
- `GET /api/preview-deployer/deployments`:列出由控制面触发和恢复的部署。
|
||||
- `POST /api/preview-deployer/deployments`:提交 `{ branch, commitHash? }`。
|
||||
- `GET /api/preview-deployer/deployments/{id}`:刷新队列、构建与 artifact 状态。
|
||||
- `POST /api/preview-deployer/deployments/{id}/uninstall`:触发固定 Job 的卸载动作。
|
||||
|
||||
页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
|
||||
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
|
||||
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
|
||||
- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
|
||||
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
|
||||
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
- 卸载只接受受控状态中存在且 ID 完全匹配的实例,并进行二次确认;禁止执行任意 Docker、Git、shell 或 Compose project 参数。
|
||||
|
||||
## 验收
|
||||
|
||||
- 后端:输入校验、登录会话、Origin、Crumb、Jenkins `401/403/404/5xx`、queue 到 build 状态机、artifact schema、卸载所有权和幂等测试。
|
||||
- 前端:登录、分支与可选 commit、自动刷新、排队/构建/成功/失败状态、内网链接、卸载确认和刷新恢复测试。
|
||||
- Jenkins:两个分支依次发布后在不同端口并存;同一分支换 commit 优先复用端口;非分支 commit 被拒绝;卸载只删除目标实例并释放端口。
|
||||
- 通用:`npm run check:encoding`、相关 typecheck/build/test、Rust 定向测试和 `git diff --check`。
|
||||
@@ -674,6 +674,8 @@ npm run container:down
|
||||
|
||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||
|
||||
多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。每个分支使用稳定 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。
|
||||
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
|
||||
|
||||
独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
timeout(time: 120, unit: 'MINUTES')
|
||||
buildDiscarder(logRotator(numToKeepStr: '50', artifactNumToKeepStr: '50'))
|
||||
}
|
||||
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments'
|
||||
}
|
||||
|
||||
parameters {
|
||||
choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL'], description: '部署、查询状态或卸载分支预览容器')
|
||||
string(name: 'SOURCE_BRANCH', defaultValue: '', description: '源码分支;DEPLOY 必填,同一分支稳定映射到同一个预览实例')
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选;必须是 SOURCE_BRANCH 历史中的 commit')
|
||||
string(name: 'DEPLOYMENT_ID', defaultValue: '', description: '可选;留空时由 SOURCE_BRANCH 稳定生成,查询和卸载可直接传部署 ID')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout Controller') {
|
||||
steps {
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: '*/master']],
|
||||
doGenerateSubmoduleConfigurations: false,
|
||||
extensions: [
|
||||
[$class: 'CleanBeforeCheckout'],
|
||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
||||
],
|
||||
userRemoteConfigs: [[
|
||||
url: env.GIT_REMOTE_URL,
|
||||
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
|
||||
refspec: '+refs/heads/master:refs/remotes/origin/master',
|
||||
]],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
stage('Validate Request') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
ACTION="${ACTION:-}"
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-}"
|
||||
COMMIT_HASH="${COMMIT_HASH:-}"
|
||||
DEPLOYMENT_ID="${DEPLOYMENT_ID:-}"
|
||||
case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac
|
||||
if [ -n "${SOURCE_BRANCH}" ]; then
|
||||
git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null
|
||||
fi
|
||||
if [ -n "${COMMIT_HASH}" ] && ! printf '%s' "${COMMIT_HASH}" | grep -Eq '^[0-9a-fA-F]{7,40}$'; then
|
||||
echo 'COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash。' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${DEPLOYMENT_ID}" ] && ! printf '%s' "${DEPLOYMENT_ID}" | grep -Eq '^preview-[0-9a-f]{16}$'; then
|
||||
echo 'DEPLOYMENT_ID 格式非法。' >&2
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout Requested Source') {
|
||||
when {
|
||||
expression { params.ACTION == 'DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
dir('source') {
|
||||
script {
|
||||
def remoteConfig = [
|
||||
url: env.GIT_REMOTE_URL,
|
||||
credentialsId: env.GIT_REMOTE_CREDENTIAL_ID,
|
||||
refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}",
|
||||
]
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||
doGenerateSubmoduleConfigurations: false,
|
||||
extensions: [
|
||||
[$class: 'CleanBeforeCheckout'],
|
||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
||||
],
|
||||
userRemoteConfigs: [remoteConfig],
|
||||
])
|
||||
}
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH}" \
|
||||
COMMIT_HASH="${COMMIT_HASH}" \
|
||||
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
|
||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||
GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \
|
||||
GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
"${WORKSPACE}/scripts/jenkins-checkout-source.sh"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Execute Preview Action') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
chmod +x scripts/jenkins-preview-deployer.sh
|
||||
SOURCE_DIR="${WORKSPACE}/source" \
|
||||
RESULT_FILE="${WORKSPACE}/preview-result.json" \
|
||||
DESCRIPTION_FILE="${WORKSPACE}/.jenkins-preview-description" \
|
||||
scripts/jenkins-preview-deployer.sh
|
||||
'''
|
||||
script {
|
||||
if (fileExists('.jenkins-preview-description')) {
|
||||
currentBuild.description = readFile('.jenkins-preview-description').trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
if (!fileExists('preview-result.json')) {
|
||||
writeFile file: 'preview-result.json', text: groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
|
||||
schemaVersion: 1,
|
||||
action: params.ACTION ?: 'UNKNOWN',
|
||||
deploymentId: params.DEPLOYMENT_ID ?: null,
|
||||
projectName: params.DEPLOYMENT_ID ? "genarrative-${params.DEPLOYMENT_ID}" : null,
|
||||
branch: params.SOURCE_BRANCH ?: null,
|
||||
requestedCommit: params.COMMIT_HASH ?: null,
|
||||
resolvedCommit: null,
|
||||
status: 'failed',
|
||||
health: 'unknown',
|
||||
webUrl: null,
|
||||
message: 'Jenkins 在生成部署结果前失败,请查看构建日志。',
|
||||
updatedAt: new Date().format("yyyy-MM-dd'T'HH:mm:ssXXX"),
|
||||
])) + '\n'
|
||||
}
|
||||
}
|
||||
archiveArtifacts artifacts: 'preview-result.json', allowEmptyArchive: false, fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user