From ad0ded5e584a2b27b86681813157c24059ff19c9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Apr 2026 02:19:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0jenkins=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md | 169 ++++++++++++++++++ jenkins/Jenkinsfile.build | 57 ++++++ jenkins/Jenkinsfile.build-and-deploy | 77 ++++++++ jenkins/Jenkinsfile.deploy | 85 +++++++++ scripts/deploy-rust-remote.sh | 20 +++ scripts/jenkins-deploy-release.sh | 98 ++++++++++ 6 files changed, 506 insertions(+) create mode 100644 docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md create mode 100644 jenkins/Jenkinsfile.build create mode 100644 jenkins/Jenkinsfile.build-and-deploy create mode 100644 jenkins/Jenkinsfile.deploy create mode 100644 scripts/jenkins-deploy-release.sh diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md new file mode 100644 index 00000000..3289d462 --- /dev/null +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -0,0 +1,169 @@ +# Jenkins Rust 构建与部署流水线方案 + +日期:`2026-04-23` + +## 1. 目标 + +本方案为当前仓库补齐 3 条 Jenkins 流水线: + +1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。 +2. `部署`:只负责把指定发布版本部署到 `/home/ubuntu/Genarrative-deploy/`,禁止人工直接点击执行。 +3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成。 + +本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。 + +## 2. 执行约束 + +1. 构建产物目录统一使用 `build/<版本号>/`。 +2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`。 +3. `部署` 流水线必须校验当前构建原因包含 `UpstreamCause`,没有上游触发则直接失败。 +4. `部署` 流水线额外校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致。 +5. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁。 +6. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录。 + +## 3. 节点与工作区要求 + +这套方案依赖“本地目录发布”,因此有两个前提: + +1. `构建并部署` 与 `部署` 必须落到同一台 Ubuntu Jenkins Agent,或者落到同一块共享文件系统。 +2. `构建并部署` 触发 `部署` 时,必须把 `SOURCE_NODE_NAME` 和 `SOURCE_WORKSPACE_ROOT` 一并传下去。 + +仓库中提供的 Jenkinsfile 已按这个约束实现: + +1. `构建` / `构建并部署` 在指定源码目录内 `checkout scm` 并生成 `build/<版本号>/`。 +2. `构建并部署` 结束构建节点占用后,再触发 `部署`。 +3. `部署` 优先按 `SOURCE_NODE_NAME` 调度到同名节点,再读取 `SOURCE_WORKSPACE_ROOT/build/<版本号>/`。 + +## 4. 三条流水线定义 + +### 4.1 构建 + +脚本路径: + +```text +jenkins/Jenkinsfile.build +``` + +核心流程: + +1. 可选执行 `npm ci`。 +2. 在源码根目录执行: + +```bash +npm run deploy:rust:remote -- --skip-upload --name +``` + +3. 校验 `build//` 存在。 +4. 归档 `build//**` 作为 Jenkins 产物。 + +默认版本号: + +```text +BUILD_VERSION = Jenkins BUILD_NUMBER +``` + +### 4.2 部署 + +脚本路径: + +```text +jenkins/Jenkinsfile.deploy +``` + +核心流程: + +1. 校验触发原因必须是上游流水线,而不是人工点击。 +2. 校验 `BUILD_VERSION`、`SOURCE_WORKSPACE_ROOT`、`DEPLOY_DIRECTORY` 非空。 +3. 执行: + +```bash +scripts/jenkins-deploy-release.sh \ + --source-dir /build/ \ + --deploy-dir /home/ubuntu/Genarrative-deploy +``` + +脚本语义: + +1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`。 +2. 直接清空部署目录中的全部旧文件。 +3. 将指定版本目录中的内容移动到部署目录。 +4. 执行新版本 `start.sh`。 + +这样可以满足你要求的“直接覆盖部署目录中的所有文件”。同时这也意味着部署目录内原有的 `.env`、`.env.local`、日志和本地 SpacetimeDB 数据都会被清掉,最终以构建产物中的文件为准。 + +### 4.3 构建并部署 + +脚本路径: + +```text +jenkins/Jenkinsfile.build-and-deploy +``` + +核心流程: + +1. 复用与 `构建` 相同的构建命令生成 `build//`。 +2. 归档 `build//**`。 +3. 记录当前 `NODE_NAME`、源码根目录、版本号。 +4. 触发 `部署` 流水线,并传递: + - `BUILD_VERSION` + - `SOURCE_WORKSPACE_ROOT` + - `SOURCE_NODE_NAME` + - `DEPLOY_DIRECTORY` + - `EXPECTED_UPSTREAM_JOB` + +## 5. Jenkins 参数建议 + +三条流水线统一建议暴露以下参数: + +1. `AGENT_LABEL`:默认执行节点标签。 +2. `GENARRATIVE_WORKSPACE_ROOT`:源码根目录;为空时回退到 Jenkins 当前工作区。 +3. `BUILD_VERSION`:发布版本号;为空时回退到 `BUILD_NUMBER`。 +4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`。 + +其中仅 `部署` 流水线还需要: + +1. `SOURCE_WORKSPACE_ROOT` +2. `SOURCE_NODE_NAME` +3. `DEPLOY_DIRECTORY` +4. `EXPECTED_UPSTREAM_JOB` + +其中仅 `构建并部署` 流水线还需要: + +1. `DEPLOY_JOB_NAME` + +## 6. 推荐 Job 命名 + +建议在 Jenkins 中创建以下 3 个 Pipeline Job,并分别指向仓库中的脚本路径: + +1. `Genarrative-Build` -> `jenkins/Jenkinsfile.build` +2. `Genarrative-Deploy` -> `jenkins/Jenkinsfile.deploy` +3. `Genarrative-Build-And-Deploy` -> `jenkins/Jenkinsfile.build-and-deploy` + +同时给 `Genarrative-Deploy` 配置环境变量: + +```text +GENARRATIVE_ALLOWED_UPSTREAM_JOB=Genarrative-Build-And-Deploy +``` + +如果 Job 在 Jenkins Folder 下,值应填写完整上游作业名,例如: + +```text +game/Genarrative-Build-And-Deploy +``` + +## 7. 文件清单 + +本方案对应的仓库文件: + +```text +jenkins/Jenkinsfile.build +jenkins/Jenkinsfile.deploy +jenkins/Jenkinsfile.build-and-deploy +scripts/jenkins-deploy-release.sh +``` + +## 8. 风险与边界 + +1. 该方案依赖本地目录切换,不适用于“构建节点”和“部署节点”完全隔离且不共享文件系统的 Jenkins 架构。 +2. 当前 `部署` 采取的是“覆盖固定部署目录”的方式,不包含版本回滚目录管理;如需保留完整历史版本,应在后续单独补一层 release/current 软链接结构。 +3. 当前 `start.sh` / `stop.sh` 仍以发布包内脚本为准,不替代 `systemd`、`supervisor`、`nginx`、`tls` 与日志轮转治理。 diff --git a/jenkins/Jenkinsfile.build b/jenkins/Jenkinsfile.build new file mode 100644 index 00000000..a42ff919 --- /dev/null +++ b/jenkins/Jenkinsfile.build @@ -0,0 +1,57 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + } + + parameters { + string(name: 'AGENT_LABEL', defaultValue: 'linux', description: '构建节点标签') + string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') + } + + stages { + stage('构建发布包') { + agent { + label "${params.AGENT_LABEL}" + } + + steps { + script { + // 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。 + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 + env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() + } + + dir("${env.WORKSPACE_ROOT}") { + checkout scm + + script { + // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 + if (params.RUN_NPM_CI) { + sh 'npm ci' + } + } + + sh """ + set -euo pipefail + npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" + test -d "build/${env.EFFECTIVE_BUILD_VERSION}" + """ + + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true + } + } + } + } + + post { + success { + echo "构建完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}" + } + } +} diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy new file mode 100644 index 00000000..b10cb7cd --- /dev/null +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -0,0 +1,77 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + } + + parameters { + string(name: 'AGENT_LABEL', defaultValue: 'linux', description: '构建节点标签') + string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') + string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') + booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') + string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名') + string(name: 'DEPLOY_DIRECTORY', defaultValue: '/home/ubuntu/Genarrative-deploy', description: '固定部署目录') + } + + stages { + stage('构建发布包') { + agent { + label "${params.AGENT_LABEL}" + } + + steps { + script { + // 统一在脚本块里计算版本号,避免 declarative environment 对表达式求值不一致。 + env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER + // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 + env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() + // 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。 + env.SOURCE_NODE_NAME = env.NODE_NAME + } + + dir("${env.WORKSPACE_ROOT}") { + checkout scm + + script { + // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 + if (params.RUN_NPM_CI) { + sh 'npm ci' + } + } + + sh """ + set -euo pipefail + npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" + test -d "build/${env.EFFECTIVE_BUILD_VERSION}" + """ + + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/**", fingerprint: true + } + } + } + + stage('触发部署') { + steps { + // 本阶段没有声明 agent,确保触发下游前已经释放构建节点,避免单执行器死锁。 + build job: params.DEPLOY_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_NODE_NAME', value: env.SOURCE_NODE_NAME), + string(name: 'SOURCE_WORKSPACE_ROOT', value: env.WORKSPACE_ROOT), + string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), + string(name: 'DEPLOY_DIRECTORY', value: params.DEPLOY_DIRECTORY), + string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME), + ] + } + } + } + + post { + success { + echo "构建并部署完成,版本号: ${env.EFFECTIVE_BUILD_VERSION}" + } + } +} diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy new file mode 100644 index 00000000..f803216a --- /dev/null +++ b/jenkins/Jenkinsfile.deploy @@ -0,0 +1,85 @@ +pipeline { + agent none + + options { + disableConcurrentBuilds() + timestamps() + } + + parameters { + string(name: 'SOURCE_NODE_NAME', defaultValue: '', description: '上游构建节点名') + string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录') + string(name: 'BUILD_VERSION', defaultValue: '', description: '待部署版本号') + string(name: 'DEPLOY_DIRECTORY', defaultValue: '/home/ubuntu/Genarrative-deploy', description: '固定部署目录') + string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名') + } + + stages { + stage('校验触发来源') { + agent { + label 'built-in' + } + + steps { + script { + def upstreamCause = currentBuild.rawBuild.getCause(hudson.model.Cause$UpstreamCause) + if (upstreamCause == null) { + error('部署流水线禁止人工直接执行,只允许由上游构建并部署流水线触发。') + } + + def actualUpstreamJob = upstreamCause.upstreamProject ?: '' + def expectedUpstreamJob = params.EXPECTED_UPSTREAM_JOB?.trim() + def allowedUpstreamJob = env.GENARRATIVE_ALLOWED_UPSTREAM_JOB?.trim() + + if (!params.BUILD_VERSION?.trim()) { + error('BUILD_VERSION 不能为空。') + } + + if (!params.SOURCE_WORKSPACE_ROOT?.trim()) { + error('SOURCE_WORKSPACE_ROOT 不能为空。') + } + + if (!params.SOURCE_NODE_NAME?.trim()) { + error('SOURCE_NODE_NAME 不能为空。') + } + + if (expectedUpstreamJob && actualUpstreamJob != expectedUpstreamJob) { + error("上游作业校验失败,期望 ${expectedUpstreamJob},实际 ${actualUpstreamJob}") + } + + if (allowedUpstreamJob && actualUpstreamJob != allowedUpstreamJob) { + error("环境门禁校验失败,仅允许 ${allowedUpstreamJob} 触发,实际 ${actualUpstreamJob}") + } + + env.UPSTREAM_JOB_NAME = actualUpstreamJob + } + } + } + + stage('部署指定版本') { + agent { + label "${params.SOURCE_NODE_NAME}" + } + + steps { + dir("${params.SOURCE_WORKSPACE_ROOT}") { + sh """ + set -euo pipefail + test -d "build/${params.BUILD_VERSION}" + chmod +x scripts/jenkins-deploy-release.sh + # 只部署上游已构建好的版本目录,避免部署阶段再次构建产生漂移。 + ./scripts/jenkins-deploy-release.sh \ + --source-dir "build/${params.BUILD_VERSION}" \ + --deploy-dir "${params.DEPLOY_DIRECTORY}" + """ + } + } + } + } + + post { + success { + echo "部署完成,版本号: ${params.BUILD_VERSION},上游作业: ${env.UPSTREAM_JOB_NAME}" + } + } +} diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 0a0903f1..2ef5ad3e 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -57,6 +57,22 @@ copy_required_file() { cp "${source_path}" "${target_path}" } +copy_optional_file() { + local source_path="$1" + local target_path_a="$2" + local target_path_b="$3" + local label="$4" + + if [[ ! -f "${source_path}" ]]; then + echo "[deploy:rust] 跳过未找到的可选文件 ${label}: ${source_path}" + return + fi + + cp "${source_path}" "${target_path_a}" + cp "${source_path}" "${target_path_b}" + echo "[deploy:rust] 已复制 ${label} -> ${target_path_a} 与 ${target_path_b}" +} + normalize_local_path_for_bash() { local value="$1" @@ -228,6 +244,9 @@ mkdir -p "${WEB_DIR}" echo "[deploy:rust] 发布包目录: ${TARGET_DIR}" +copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env" +copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local" + if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}" ( @@ -571,6 +590,7 @@ cat >"${TARGET_DIR}/README.md" < 必填,待部署的发布目录,例如 build/123 + --deploy-dir 必填,固定部署目录,例如 /home/ubuntu/Genarrative-deploy +EOF +} + +require_argument() { + local value="$1" + local label="$2" + + if [[ -z "${value}" ]]; then + echo "[jenkins-deploy] 缺少参数: ${label}" >&2 + exit 1 + fi +} + +SOURCE_DIR="" +DEPLOY_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source-dir) + SOURCE_DIR="${2:?缺少 --source-dir 的值}" + shift 2 + ;; + --deploy-dir) + DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}" + shift 2 + ;; + *) + echo "[jenkins-deploy] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_argument "${SOURCE_DIR}" "--source-dir" +require_argument "${DEPLOY_DIR}" "--deploy-dir" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "[jenkins-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2 + exit 1 +fi + +SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)" +mkdir -p "${DEPLOY_DIR}" +DEPLOY_DIR="$(cd "${DEPLOY_DIR}" && pwd)" + +if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then + echo "[jenkins-deploy] 发布目录缺少 start.sh: ${SOURCE_DIR}" >&2 + exit 1 +fi + +if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then + echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}" + ( + cd "${DEPLOY_DIR}" + ./stop.sh + ) +else + echo "[jenkins-deploy] 部署目录无可执行 stop.sh,跳过停服" +fi + +echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}" +find "${DEPLOY_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + +echo "[jenkins-deploy] 移动发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}" +find "${SOURCE_DIR}" -mindepth 1 -maxdepth 1 -exec mv {} "${DEPLOY_DIR}/" \; + +chmod +x "${DEPLOY_DIR}/start.sh" "${DEPLOY_DIR}/stop.sh" + +echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}" +( + cd "${DEPLOY_DIR}" + ./start.sh +) + +echo "[jenkins-deploy] 完成"