Merge remote-tracking branch 'origin/master' into codex/agc-runtime-generation-reliability

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
kdletters
2026-08-17 15:12:43 +08:00
40 changed files with 7125 additions and 2 deletions
+2
View File
@@ -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
+13
View File
@@ -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>
+26
View File
@@ -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,196 @@
/* @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.searchBranches).mockResolvedValue([]);
vi.mocked(api.searchCommits).mockResolvedValue([]);
vi.mocked(api.createDeployment).mockResolvedValue({
id: null,
branch: 'master',
status: 'queued',
health: 'pending',
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
});
vi.mocked(api.uninstallDeployment).mockResolvedValue({
id: '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: '42',
branch: 'feature/demo',
resolvedCommit: '1234567890abcdef',
status: 'running',
health: 'healthy',
webPort: 8400,
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.getByText('端口 8400')).toBeTruthy();
expect(screen.getByText('构建 #42')).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('42');
});
});
test('shows debounced branch results and selects one with the keyboard', async () => {
const user = userEvent.setup();
vi.mocked(api.searchBranches).mockResolvedValue([
{ name: 'feature/search-one', commitHash: '111111111111' },
{ name: 'feature/search-two', commitHash: '222222222222' },
]);
render(<PreviewDeployerApp />);
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
await user.clear(branchInput);
await user.type(branchInput, 'feature/search');
expect(
await screen.findByRole('option', { name: /feature\/search-one/u }),
).toBeTruthy();
expect(api.searchBranches).toHaveBeenLastCalledWith(
'feature/search',
expect.any(AbortSignal),
);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect((branchInput as HTMLInputElement).value).toBe('feature/search-two');
});
test('searches commits in the current branch and selects a result', async () => {
const user = userEvent.setup();
vi.mocked(api.searchCommits).mockResolvedValue([
{
commitHash: 'aa5221abcdef0123456789',
shortHash: 'aa5221a',
subject: '补充预览搜索',
},
]);
render(<PreviewDeployerApp />);
const commitInput = await screen.findByRole('combobox', {
name: 'Commit Hash',
});
await user.type(commitInput, 'aa5221a');
const option = await screen.findByRole('option', {
name: /aa5221a.*/u,
});
expect(api.searchCommits).toHaveBeenCalledWith(
'master',
'aa5221a',
expect.any(AbortSignal),
);
await user.click(option);
expect((commitInput as HTMLInputElement).value).toBe(
'aa5221abcdef0123456789',
);
});
test('clears commit and ignores stale commit responses when branch changes', async () => {
const user = userEvent.setup();
let resolveOldSearch: ((items: api.CommitRef[]) => void) | undefined;
vi.mocked(api.searchCommits).mockImplementation(
() =>
new Promise<api.CommitRef[]>((resolve) => {
resolveOldSearch = resolve;
}),
);
render(<PreviewDeployerApp />);
const branchInput = await screen.findByRole('combobox', { name: '分支名' });
const commitInput = screen.getByRole('combobox', { name: 'Commit Hash' });
await user.type(commitInput, 'abcdef1');
await waitFor(() => expect(api.searchCommits).toHaveBeenCalled());
await user.clear(branchInput);
await user.type(branchInput, 'feature/new');
expect((commitInput as HTMLInputElement).value).toBe('');
resolveOldSearch?.([
{
commitHash: 'abcdef1234567890',
shortHash: 'abcdef1',
subject: '旧分支提交',
},
]);
await Promise.resolve();
expect(screen.queryByText('旧分支提交')).toBeNull();
});
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
createDeployment,
createSession,
deleteSession,
getSession,
listDeployments,
searchBranches,
searchCommits,
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' }),
);
});
test('searches branches and branch-scoped commits with encoded queries', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [{ name: 'feature/search', commitHash: 'abcdef123456' }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{
commitHash: 'abcdef123456',
shortHash: 'abcdef1',
subject: '搜索提交',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
expect(await searchBranches('feature/search')).toHaveLength(1);
expect(await searchCommits('feature/search', 'abc def')).toHaveLength(1);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/preview-deployer/refs/branches?q=feature%2Fsearch',
expect.objectContaining({ credentials: 'same-origin' }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/preview-deployer/refs/commits?branch=feature%2Fsearch&q=abc%20def',
expect.objectContaining({ credentials: 'same-origin' }),
);
});
+159
View File
@@ -0,0 +1,159 @@
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 interface BranchRef {
name: string;
commitHash?: string | null;
}
export interface CommitRef {
commitHash: string;
shortHash: string;
subject?: string | null;
}
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 async function searchBranches(query: string, signal?: AbortSignal) {
const payload = await request<BranchRef[] | { items: BranchRef[] }>(
`/refs/branches?q=${encodeURIComponent(query)}`,
{ signal },
);
return Array.isArray(payload) ? payload : payload.items;
}
export async function searchCommits(
branch: string,
query: string,
signal?: AbortSignal,
) {
const payload = await request<CommitRef[] | { items: CommitRef[] }>(
`/refs/commits?branch=${encodeURIComponent(branch)}&q=${encodeURIComponent(query)}`,
{ signal },
);
return Array.isArray(payload) ? payload : payload.items;
}
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;
}
+17
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
export type DeploymentStatus =
| 'queued'
| 'building'
| 'deploying'
| 'running'
| 'uninstalling'
| 'stopped'
| 'failed'
| 'cancelled';
export type DeploymentHealth = 'pending' | 'healthy' | 'unhealthy' | 'unknown';
export interface PreviewDeployment {
id?: string | null;
branch: string;
commitHash?: string | null;
resolvedCommit?: string | null;
status: DeploymentStatus;
health: DeploymentHealth;
webPort?: number | null;
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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -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"]
}
+36
View File
@@ -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,
},
});
+1 -1
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
# 仅部署在本机内网 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/
# 页面“构建详情”链接使用局域网地址,内部请求仍使用上面的 loopback 地址。
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_USERNAME=preview-deployer-service
GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_API_TOKEN=<由Jenkins管理员生成的专用API Token>
# 只读源码查询固定使用本机 Gitea SSH 入口,控制服务不接受客户端传入 remote。
GENARRATIVE_PREVIEW_DEPLOYER_GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git
# 使用 Jenkins 用户专用只读 deploy key 与独立 known_hosts;禁止关闭主机密钥校验。
GENARRATIVE_PREVIEW_DEPLOYER_GIT_SSH_COMMAND=ssh -i /var/lib/jenkins/.ssh/genarrative-preview-readonly -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/var/lib/jenkins/.ssh/known_hosts
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
+1
View File
@@ -45,6 +45,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)
@@ -14192,6 +14192,20 @@
- Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。
- Provider Schema 决策:`agent.route_manifest.missingAssetSlots` 不再广告 OpenAI-compatible 代理拒绝的 `uniqueItems`Runtime 继续排序去重,Schema 子集门禁新增该关键字,真实 Provider smoke 必须在发布前证明工具目录可被接受。
- 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。
## 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`
## 2026-08-17 预览发布记录使用 Jenkins 构建编号并有限保留
- 决策:内部稳定 `deploymentId` 继续绑定分支、Compose project 和端口租约;页面/API 记录 ID 在 Jenkins 分配执行器后改为构建编号,排队阶段为“待分配”。卸载通过构建编号找到内部实例,再向固定 Job 传内部 ID。
- 清理:失败或取消且不存在可卸载实例的记录保留 7 天;成功卸载的内部审计记录保留 30 天;仍可卸载的失败记录永久保留到人工卸载。服务启动、读取列表和创建部署时执行清理并原子落盘。
- 链接:服务内部仍通过 Jenkins loopback 轮询;只向浏览器返回由 `GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL` 构造的局域网构建详情地址,禁止回传 loopback URL。
## 2026-08-16 AGC 直连回合的 revision 与项目版本同步
@@ -0,0 +1,121 @@
# 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。
## 部署身份与端口
预览实例 ID`deploymentId`)由规范化分支名与分支名摘要确定,只在控制服务内部和 Jenkins 参数中使用;commit 不进入该 ID,因此同一分支重新构建或指定不同 commit 时复用同一预览实例和 Web 端口。页面发布记录的公开 ID 使用 Jenkins 构建编号:排队期间尚未分配编号,显示“待分配”,构建开始后立即显示真实编号。
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>`
SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=remote,migrate,break-clients`,避免 Jenkins 等待非本地目标交互确认;该预览路径不传 `--delete-data`。Jenkins 同时固定 `GENARRATIVE_PREVIEW_WEB_HOST=192.168.35.82`,不得用默认路由自动探测结果生成页面链接,以免 VPN 或容器网卡地址泄漏到同事可见 URL。
预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。
## 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`:列出由控制面触发和恢复的部署。
- `GET /api/preview-deployer/refs/branches?q=`:按输入内容搜索固定源码仓库中的分支,最多返回 20 条 `{ name, commitHash }`
- `GET /api/preview-deployer/refs/commits?branch=&q=`:在已确认存在的目标分支历史中搜索 commit,最多返回 20 条 `{ commitHash, shortHash, subject }`
- `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 构建编号、后端校验后的 `webPort` 和由控制服务转换的 Jenkins 局域网构建详情地址;内部 loopback Jenkins 地址不得返回浏览器。失败或取消且不存在可卸载实例的记录保留 7 天,成功卸载的内部记录保留 30 天,清理会在服务启动、读取列表和创建新构建时执行。仍可卸载的失败记录不会自动清理。`stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。
分支名和 commit 输入框采用 300ms 防抖搜索,并在输入框下方显示服务端结果;分支变化时清空已输入的 commit,避免把旧分支 commit 带入新请求。搜索结果只负责辅助填写,不作为构建授权或存在性真相。`POST /deployments` 在写入排队状态和触发 Jenkins 前必须重新查询固定远端:分支不存在时拒绝;填写 commit 时必须确认它可解析为 commit object 且是目标分支 HEAD 的祖先。远端查询失败时失败关闭,不得触发 Jenkins。Jenkins checkout 继续执行相同的最终归属校验,以覆盖预检到排队之间的分支变化。
Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`
控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。
## 安全边界
- 服务端缺少控制面访问口令或 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 凭据。
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
- Jenkins POST 支持动态 CrumbAPI 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 TokenSPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 JenkinsJenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose projectWeb 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `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 排空另行验证。
+152
View File
@@ -0,0 +1,152 @@
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'
GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'
}
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" \
bash "${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