Initialize PROMPT environment and ces-control deployment pipeline
PROMPT CI and deployment / verify-and-deploy (push) Successful in 3m53s

This commit is contained in:
2026-10-22 20:43:02 +08:00
commit 68419e3852
17 changed files with 2587 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
name: PROMPT CI and deployment
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
verify-and-deploy:
runs-on: ces-control
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Production build
run: npm run build
- name: Test standalone production server
run: npm run test:smoke
- name: Deploy persistent systemd service
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
env:
RELEASE_SHA: ${{ gitea.sha }}
run: bash scripts/deploy.sh "$RELEASE_SHA" "$(command -v node)"
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.next/
coverage/
.env*
!.env.example
*.tsbuildinfo
.DS_Store
+1
View File
@@ -0,0 +1 @@
24
+60
View File
@@ -0,0 +1,60 @@
# PROMPT
仓库全名:**Probably Rendering One More Pixel Tonight**。日常名称、包名和服务名统一使用 PROMPT / `prompt`。
个人图文、动效与视频实验项目。当前仅搭建开发环境及 CI/CD,没有业务页面;访问 `/` 返回 404 是预期行为。
## 技术栈
Next.js Pages Router、React、TypeScript(strict)、Tailwind CSS、Node.js 24。依赖版本由 `package-lock.json` 固定。
```bash
nvm use
npm ci
npm run dev
```
健康检查:`GET /api/health`。支持 HEAD,拒绝写入请求,并返回当前部署 commit。
## 验证
```bash
npm run typecheck
npm test
npm run build
npm run test:smoke
```
Smoke test 会启动 standalone 生产服务,验证 HTTP 健康检查、release 标识及不支持的方法,随后停止测试进程。默认自动选择可用测试端口,可设置 `SMOKE_PORT` 覆盖。
## Gitea Actions 与持久部署
工作流为 `.gitea/workflows/ci.yml`,使用 `runs-on: ces-control`。PR 运行验证;main 分支 push 或在 main 上手动执行会在全部检查通过后部署。
Runner 前提:Linux 宿主机可访问 systemd,提供 Bash、Git、flock、基础文件工具及 root 或免密 sudo。Runner 的 label 必须让 Job 在该宿主机环境执行;容器里的 host 模式不一定就是物理宿主机。如果这些条件不满足,部署会明确失败,不能将 CI 中临时启动的进程当成持久服务。
仓库需要开启 Actions,且 `ces-control` runner 对仓库可用。Checkout 使用 Gitea Actions 提供的临时凭据,不在仓库保存密码或长期 token。
部署约定:
| 项目 | 值 |
| --- | --- |
| 服务 | `prompt.service` |
| 系统用户 | `prompt` |
| HTTP 端口 | `18131` |
| 发布目录 | `/opt/prompt/releases/<commit>` |
| 当前发布 | `/opt/prompt/current` |
| 持久数据目录 | `/var/lib/prompt` |
| 健康检查 | `http://127.0.0.1:18131/api/health` |
发布包含独立 Node 运行时。systemd 负责开机启动和故障重启;部署使用宿主机文件锁串行执行,跳过落后的 commit,通过原子软链接切换版本。新版本健康检查失败时恢复旧版本;首次发布失败则停止服务。数据目录不随 checkout 清理或版本切换而删除,当前尚未引入数据库或用户素材。
端口对外可达性取决于目标机器网络和防火墙;工作流不会修改防火墙。CI 日志会输出部署主机名及地址。当前没有业务接口或 UI,后续再确定图文、视频模块的包和设计系统。
维护命令(在目标宿主机执行):
```bash
sudo systemctl status prompt.service
sudo journalctl -u prompt.service -n 100 --no-pager
curl -fsS http://127.0.0.1:18131/api/health
```
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
};
export default nextConfig;
+2197
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "prompt",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=24 <25"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/*.test.ts",
"test:smoke": "node scripts/smoke.mjs"
},
"dependencies": {
"next": "16.4.2",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tailwindcss": "4.3.3",
"tsx": "4.23.15",
"typescript": "5.9.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { "@tailwindcss/postcss": {} },
};
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Run on the actual ces-control host, with root or passwordless sudo.
if [ "$(id -u)" -ne 0 ]; then
exec sudo -n bash "$0" "$@"
fi
RELEASE_SHA="${1:?Release SHA is required}"
NODE_SOURCE="${2:?Node executable path is required}"
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid release SHA'; exit 1; }
test -x "$NODE_SOURCE"
test -d /run/systemd/system || { echo 'Deployment requires the host systemd environment'; exit 1; }
systemctl show-environment >/dev/null
test -f .next/standalone/server.js
APP_ROOT=/opt/prompt
DATA_ROOT=/var/lib/prompt
SERVICE=prompt.service
SERVICE_USER=prompt
PORT=18131
RELEASE_DIR="$APP_ROOT/releases/$RELEASE_SHA"
mkdir -p "$APP_ROOT/releases"
exec 9>"$APP_ROOT/.deploy.lock"
flock -w 300 9 || { echo 'Another PROMPT deployment is running'; exit 1; }
# Do not replace the release of a newer main commit after a slower old run.
if [ "$(git -c safe.directory="$PWD" rev-parse HEAD)" != "$RELEASE_SHA" ]; then
echo 'Checkout does not match release SHA'; exit 1
fi
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
echo 'Skipping deployment: main has a newer commit'
exit 0
fi
PREVIOUS=""
if [ -L "$APP_ROOT/current" ] && [ -d "$APP_ROOT/current" ]; then
PREVIOUS=$(readlink -f "$APP_ROOT/current")
fi
if [ -z "$PREVIOUS" ]; then
"$NODE_SOURCE" -e "const s=require('net').createServer();s.once('error',()=>process.exit(1));s.listen($PORT,'0.0.0.0',()=>s.close());" || {
echo "Port $PORT is already occupied"; exit 1;
}
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --home "$DATA_ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$DATA_ROOT"
# Keep runtime data outside the checkout and releases; future modules can use it.
if [ ! -d "$RELEASE_DIR" ]; then
STAGING=$(mktemp -d "$APP_ROOT/releases/.staging.XXXXXX")
cp -a .next/standalone/. "$STAGING/"
mkdir -p "$STAGING/.next" "$STAGING/bin"
cp -a .next/static "$STAGING/.next/static"
if [ -d public ]; then cp -a public "$STAGING/public"; fi
install -m 0755 "$NODE_SOURCE" "$STAGING/bin/node"
printf 'RELEASE_SHA=%s\n' "$RELEASE_SHA" > "$STAGING/release.env"
chmod -R a+rX "$STAGING"
chmod 0755 "$STAGING"
mv "$STAGING" "$RELEASE_DIR"
fi
cat > /etc/systemd/system/prompt.service <<EOF
[Unit]
Description=PROMPT
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$APP_ROOT/current
Environment=NODE_ENV=production
Environment=HOSTNAME=0.0.0.0
Environment=PORT=$PORT
Environment=PROMPT_DATA_DIR=$DATA_ROOT
EnvironmentFile=$APP_ROOT/current/release.env
ExecStart=$APP_ROOT/current/bin/node server.js
Restart=always
RestartSec=5
NoNewPrivileges=true
UMask=0027
[Install]
WantedBy=multi-user.target
EOF
SWITCHED=0
rollback() {
local code=$?
trap - ERR
set +e
if [ "$SWITCHED" -eq 1 ]; then
if [ -n "$PREVIOUS" ]; then
ln -sfn "$PREVIOUS" "$APP_ROOT/.current.rollback"
mv -Tf "$APP_ROOT/.current.rollback" "$APP_ROOT/current"
systemctl restart "$SERVICE"
echo 'Restored previous release'
else
systemctl disable --now "$SERVICE"
rm -f "$APP_ROOT/current"
echo 'Stopped failed first deployment'
fi
fi
echo "Deployment failed (exit $code)"
exit "$code"
}
trap rollback ERR
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
SWITCHED=1
systemctl daemon-reload
systemctl enable "$SERVICE"
systemctl restart "$SERVICE"
HEALTHY=0
for attempt in $(seq 1 30); do
if "$NODE_SOURCE" -e "fetch('http://127.0.0.1:$PORT/api/health').then(async r=>{const d=await r.json();if(!r.ok||d.service!=='prompt'||d.release!=='$RELEASE_SHA')process.exit(1)}).catch(()=>process.exit(1))"; then
HEALTHY=1
break
fi
sleep 2
done
if [ "$HEALTHY" -ne 1 ]; then
echo 'Health check failed; rolling back'
false
fi
systemctl is-enabled "$SERVICE"
systemctl is-active "$SERVICE"
echo "Persistent deployment verified: service=$SERVICE port=$PORT release=$RELEASE_SHA"
echo "Host: $(hostname)"
hostname -I || true
+44
View File
@@ -0,0 +1,44 @@
import { spawn } from "node:child_process";
import assert from "node:assert/strict";
import { setTimeout } from "node:timers/promises";
import { createServer } from "node:net";
import { once } from "node:events";
async function availablePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
return String(port);
}
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const child = spawn(process.execPath, [".next/standalone/server.js"], {
env: { ...process.env, PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
stdio: "inherit",
});
try {
let ready = false;
for (let i = 0; i < 40; i++) {
if (child.exitCode !== null) throw new Error(`Server exited: ${child.exitCode}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: "ok", service: "prompt", release });
ready = true;
break;
} catch { await setTimeout(500); }
}
assert.ok(ready, "Standalone production server must be healthy");
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { method: "POST" });
assert.equal(response.status, 405);
console.log("Production smoke test passed");
} finally {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
}
+6
View File
@@ -0,0 +1,6 @@
export function healthResponse(method: string | undefined, release = "development") {
if (method !== "GET" && method !== "HEAD") {
return { statusCode: 405, body: { error: "Method not allowed" } };
}
return { statusCode: 200, body: { status: "ok", service: "prompt", release } };
}
+6
View File
@@ -0,0 +1,6 @@
import type { AppProps } from "next/app";
import "@/styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { healthResponse } from "@/lib/health";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { statusCode, body } = healthResponse(req.method, process.env.RELEASE_SHA);
res.setHeader("Cache-Control", "no-store");
if (statusCode === 405) res.setHeader("Allow", "GET, HEAD");
if (req.method === "HEAD") return res.status(statusCode).end();
return res.status(statusCode).json(body);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { healthResponse } from "../src/lib/health";
test("health identifies the deployed release", () => {
assert.deepEqual(healthResponse("GET", "abc123"), {
statusCode: 200,
body: { status: "ok", service: "prompt", release: "abc123" },
});
});
test("HEAD is supported and mutations are rejected", () => {
assert.equal(healthResponse("HEAD").statusCode, 200);
assert.equal(healthResponse("POST").statusCode, 405);
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}