Initialize PROMPT environment and ces-control deployment pipeline
PROMPT CI and deployment / verify-and-deploy (push) Successful in 3m53s
PROMPT CI and deployment / verify-and-deploy (push) Successful in 3m53s
This commit is contained in:
@@ -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)"
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
coverage/
|
||||
.env*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -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
|
||||
```
|
||||
Vendored
+7
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2197
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user