Shipyard 是一个面向多租户、可自托管的前端 CI/CD 平台,用于部署前端项目(静态站点 + SSR)。
技术栈:后端/Worker 为 NestJS + Prisma + PostgreSQL + Redis + BullMQ,前端为 Vue 3 + Vite + Pinia + Naive UI。
另见英文版:README-EN.md
apps/server:NestJS API Server + Prisma Schemaapps/web:Vue 3 管理后台apps/mp:uni-app 微信小程序(Vue 3 + Vite + TS,与 Web 信息架构对齐)packages/shared:前后端共享的 enums/DTOs/utils(仅纯函数,不包含加密逻辑)e2e/:Playwright 端到端金路径(与pnpm test中的 Vitest 分离)
apps/monitoring-server、apps/monitoring-web、packages/monitoring-sdk、packages/monitoring-contracts:与 Shipyard 主业务 数据不绑定 的监控接收端、管理台与客户端 SDK。- 下一版(v2)规划:需求规格 · 路线图;Phase A 基线说明见 三端方案。
- 开发:复制
apps/mp/.env.example为apps/mp/.env,设置VITE_API_BASE为可公网访问的 API 根路径(如https://api.example.com/api);根目录执行pnpm dev:mp,用 微信开发者工具 打开构建输出目录(见终端提示,一般为apps/mp/dist/dev/mp-weixin)。 - 构建:
pnpm build:mp-weixin,产物在apps/mp/dist/build/mp-weixin。 - 域名:在微信公众平台配置 request 合法域名(与
VITE_API_BASE主机一致,须 HTTPS);真机与正式包无法使用未校验域名。 - 说明:小程序端使用 vue-i18n 9.x 与固定 @intlify 9.14.2,与
apps/web的 vue-i18n 11 并存;部署详情日志当前为 HTTP 轮询,非 socket.io 实时流。登录页支持查询参数redirect(仅允许以/pages/、/package-org/开头的站内路径);Token 刷新失败 时会reLaunch到登录并尽量带上当前页回跳。
- Node.js(建议 20+)
- pnpm(本仓库使用 pnpm,不使用 npm)
- Docker + Docker Compose(用于本地 postgres/redis;也可用来整套跑起来)
金路径覆盖:注册 → 组织列表 → 创建组织 → 登录 → 项目列表(空态)→ 服务器管理 → 添加 SSH 服务器。默认 pnpm test 不跑 E2E,避免拖慢日常提交。
前置
- 启动数据库与 Redis:
docker compose up -d(需与根目录.env中DATABASE_URL、REDIS_URL一致)。 - 应用迁移:
pnpm db:migrate(或pnpm db:migrate:deploy)。 - 首次安装浏览器内核:
pnpm exec playwright install chromium。
运行
pnpm test:e2e本地调试 UI:pnpm test:e2e:ui。
Playwright 会拉起 Nest dev(3000) 与 Vite dev(5173,127.0.0.1)。非 CI 时若本机已在相同端口跑好服务,会 复用已有进程(reuseExistingServer)。若一键启动异常(尤其在 Windows 上),可手动执行 pnpm dev:server 与 pnpm dev:web 后再跑 pnpm test:e2e。
说明:CI 流水线接入 E2E 需自备 Docker 服务与 playwright install;当前仓库未强制配置,可自行在 GitHub Actions 等环境中增加 job。
将 .env.example 复制为 .env 并按需修改:
cp .env.example .env常用变量说明:
DATABASE_URL:PostgreSQL 连接串REDIS_URL:Redis 连接串JWT_SECRET:JWT 签名密钥(生产必须更换)ENCRYPTION_KEY:AES-256-GCM 主密钥(生产必须更换)APP_URL:Web 管理后台 URL(邮件链接、Commit Status 的 target_url 跳转部署详情)SERVER_PUBLIC_URL:API 公网根地址(Webhook 注册与 OAuthredirect_uri,须可被 Git 平台访问)API_PUBLIC_URL(可选):与SERVER_PUBLIC_URL不一致时的 OAuth 回调基址- Git OAuth(可选):
GIT_OAUTH_*系列,见根目录.env.example ARTIFACT_STORE_PATH:构建产物目录- 构建依赖缓存:
SHIPYARD_BUILD_DEPS_CACHE_PATH、全局上限、SHIPYARD_BUILD_DEPS_CACHE_MAX_AGE_DAYS(TTL)、SHIPYARD_BUILD_DEPS_CACHE_ORG_MAX_MB(单组织上限)等(见.env.example) - Docker 构建:
SHIPYARD_BUILD_USE_DOCKER、SHIPYARD_BUILD_DOCKER_IMAGE,以及可选SHIPYARD_BUILD_DOCKER_NETWORK/_CPUS/_MEMORY/_PRIVILEGED(仅 Linux Worker,详见「Docker 构建支持矩阵」与下文运维说明)
Shipyard 通过 SSH 在远端执行 [precheck] 与部署命令时,默认多为 非登录、非交互 shell,不会自动执行 ~/.bashrc 里由 nvm 写入的 PATH。
- 不要求在目标机安装 nvm;若使用 nvm,请保证
node在 Shipyard 实际用到的 shell 的 PATH 中(例如将 nvm 初始化写入~/.bash_profile/~/.profile,或安装系统级 Node)。 - 自测:在目标机执行
bash -lc 'command -v node && node -v',结果应与 Shipyard 预检期望一致。
支持 GitHub / GitLab / Gitee / Gitea。关联账户可用 PAT 或 OAuth(Git 账户页「OAuth 授权」;需配置 .env 中 GIT_OAUTH_*,见 .env.example)。
- GitHub:注册
push与pull_request(PR 预览);若远端已有同 URL Hook 但缺少pull_request,保存预览相关设置或重新注册时会 PATCH 补齐。 - 在 Shipyard 创建项目 且已配置
SERVER_PUBLIC_URL时,会尝试在远端仓库 自动注册 Webhook;删除项目时 自动注销。 - 回调地址形如:
{SERVER_PUBLIC_URL}/api/webhooks/{github|gitlab|gitee|gitea}?p=<Project.id>,其中p为项目 UUID,用于同仓多项目路由,请勿手动改成无p的 URL。 SERVER_PUBLIC_URL必须是 Git 平台能访问到的 API 根地址(含协议与端口),与前端APP_URL(如http://localhost:5173)通常不同。- 从旧版升级:若远端仍保留 无
?p=的 Webhook,请在各平台删除旧 Hook 后,通过重新创建项目或触发重新注册,使回调带p=<Project.id>。
- Authorization callback URL 须与后端实际发出的
redirect_uri完全一致,例如:- 本地 API:
http://localhost:3000/api/git/oauth/github/callback - 或使用 ngrok 等:
https://<你的域名>/api/git/oauth/github/callback(与SERVER_PUBLIC_URL一致)
- 本地 API:
- 不要只填
http://localhost:5173:OAuth 回调由 Nest API 处理,不是 Vite 开发服务器。 - Homepage URL 可填产品首页(如
http://localhost:5173),仅作展示,不参与 redirect 校验。
引入 GitAccount OAuth 字段、GitConnection.gitAccountId 等需执行迁移:
pnpm --filter @shipyard/server db:migrate构建与部署关键状态会回写到各平台(shipyard/build、shipyard/deploy),依赖 APP_URL 生成部署详情链接。
docker compose up -d postgres redispnpm install如果 pnpm 提示忽略了某些依赖的 build scripts,可按需批准并重建:
pnpm approve-builds
pnpm rebuildpnpm --filter @shipyard/server db:generate
pnpm --filter @shipyard/server db:migrate分别在不同终端启动:
pnpm dev:server
pnpm dev:worker
pnpm dev:web访问地址:
- Web UI:
http://localhost:5173 - API(Swagger):
http://localhost:3000/api/docs
pnpm -r typecheck
pnpm -r lint
pnpm -r build- 认证:注册/登录、JWT access/refresh、忘记/重置密码
- 多租户组织:组织列表/创建、成员邀请/移除、RBAC
- 项目:项目 CRUD、Pipeline 配置、GitConnection(PAT/OAuth token 加密存储、
gitAccountId关联) - 构建流水线:BullMQ 按组织队列、
child_process构建隔离、产物.tar.gz、Redis Pub/Sub + Socket.io 日志推流 - 部署:SSH 部署(rsync + nginx/pm2 逻辑)、部署锁、健康检查 + 自动回滚
- 审批:受保护环境审批列表 + 通过/拒绝(通过后入 DeployQueue)
- Web UI:登录/注册、Dashboard、项目/环境/服务器/团队/审批、部署日志(xterm)
- Git 多平台:Webhook 接收(
p路由、验签、幂等、Webhook 触发的入队去重)、项目创建/删除时自动注册或注销 Hook、Commit Status 回写、Git 账户 PAT + OAuth(详见上文「Git 集成」)
以下为尚未实现或持续增强的方向(按“能跑通 → 可用 → 可运营”排序)。
已实现(多平台 MR/PR)
- GitHub:
pull_request(opened / synchronize / closed 等);Webhook 注册含pull_request,已存在 Hook 会自动 PATCH 补齐事件。 - GitLab:
Merge Request Hook(object_kind=merge_request);项目 Hook 开启或 PATCH 补齐merge_requests_events。 - Gitee:
merge_request_hooks;创建或 PATCH Hook 时开启merge_requests_events。 - Gitea:
pull_request;仓库 Hook 的events含push与pull_request,已存在 Hook 会 PATCH 补齐。 - 上述平台统一行为:同仓库 MR/PR 触发构建与 Linux 预览部署、关闭/合并时清理(含队列 job 取消与远端 teardown 幂等)、Fork 来源与目标不一致时跳过。
- 预览 URL:
pr-{prNumber}-{projectId前8位}.{previewBaseDomain}(项目在控制台配置「预览父域」,例如preview.example.com)。 - SSR(蓝绿):Redis 端口池;双槽位 PM2 名
…-bg0/…-bg1交替;新部署先起候选进程与健康检查(远端curl本机新端口),Nginx 片段先写临时文件再mv原子切换并重载,再摘除旧槽位/遗留名单进程名;成功后释放旧端口;失败时回滚 Redis 新端口占用,健康检查或 Nginx 失败时尽量恢复旧片段并删除候选 PM2。 - 静态站点:
releases/<deploymentId>+current软链,Nginxroot指向current。 - 预览评论(成功/失败同一条更新):GitHub(issues comments)、GitLab(MR notes)、Gitee(pull comments)、Gitea(PR 走 issues comments API,需配置实例 baseUrl);Token 需具备对应 API 写权限。
运维需一次性配置
- DNS:
*.preview.example.com(与所填父域一致)解析到入口或预览机。 - TLS:若需 HTTPS,多为泛域名证书(如 DNS-01 / acme.sh);当前自动下发的 Nginx 片段为 listen 80,可在片段外统一终止 TLS 或由运维改写。
- Nginx 主配置
http块内增加:include /etc/nginx/shipyard-previews.d/*.conf; - 防火墙:放行 SSR 所用端口区间(默认 40000–41000,可在「服务器」上配置
previewPortMin/previewPortMax)。
仍待增强(可选)
- 实例兼容:自托管 GitLab / Gitea / Gitee 版本差异可能导致 Hook PATCH 或评论 REST 路径、字段与公有云文档不一致,部署前请对照各实例官方 Webhook 与 REST API 文档做一次核对(可在下方矩阵中补充你的实例版本与结论)。
已有基础(代码中)
- 数据表
Notification(按projectId存channel、config、events[]、enabled);BullMQ 队列notify-{orgId},由 Worker 进程消费。 - REST:
GET/POST/PATCH/DELETE …/orgs/:orgSlug/projects/:projectSlug/notifications(JWT + 角色:VIEWER可读,DEVELOPER可写);管理端:项目详情 Tab「通知」。 - 事件入队:构建/部署/审批等路径调用
NotificationEnqueueApplicationService写入队列(如attempts: 3、指数退避);负载含message、detailUrl、deploymentId、projectSlug、orgSlug、event等。 - 项目级消息模板(v0.6+):
Project.notificationMessageTemplate可选;在「通知」Tab 顶部编辑。若填写,则以该字符串为骨架再做占位符替换,并可用{{message}}/{{body}}嵌入系统默认那句全文。 - 出站:
webhook(原样 POST JSON payload)、email(Nodemailer,SMTPconnectionTimeout/socketTimeout约 10s)、飞书/钉钉/Slack/企业微信(机器人 Webhook JSON;企业微信为markdown载荷)。HTTP(S) 出站前经assertSafeOutboundHttpUrl:dns.lookup(all: true)得到全部解析地址,并结合@shipyard/shared的isBlockedOutboundIp(IPv4/IPv6 私网与保留段等)。入队前message支持占位符{{projectSlug}}、{{orgSlug}}、{{event}}、{{detailUrl}}、{{deploymentId}}、{{approvalId}}(未设置的变量保留原文)。 - 敏感字段:
config内secret、smtpPass等使用CryptoService加密落库;API 响应脱敏(如secretConfigured/smtpPassConfigured)。 - SSRF 与 URL 主机:允许配置字面 IP 的
http(s)URL,对该地址直接做阻断列表校验;若为域名,则对该主机名解析得到的全部结果逐一校验,任一对私网/保留段即拒绝。 - IM
secret:secret加密存储;钉钉为毫秒时间戳 + HMAC 加签 query;飞书为秒时间戳 + 同结构加签 query(见buildFeishuSignedWebhookUrl);Slack 原生 Webhook 以 URL 为凭据,可选secret会作为Authorization: Bearer发出,便于你在网关侧校验(Slack 公网端点一般忽略该头)。 - 新建组织与 Worker:创建组织后向 Redis 发布
worker:new-org;Build / Deploy / Notify Worker 均已订阅并为新组织注册对应 BullMQ 队列,一般无需重启 Worker 进程。 - 产物保留:构建成功后按
Organization.artifactRetention对该组织下全部BuildArtifact与ARTIFACT_STORE_PATH内*.tar.gz做 count-based 清理,保留最近 N 条。 - 测试:根目录
pnpm test会先执行pnpm --filter @shipyard/shared build再跑各包测试,避免@shipyard/serverVitest 引用过期dist报错。若 CI 将 job 拆分,运行 server 测试的 job 也须先构建@shipyard/shared。GitHub Actions 见.github/workflows/ci.yml(含可选 E2E)。
仍待增强
- 通知渠道与事件矩阵可继续扩充(通用模板、签名校验等)。
- BuildWorker:按 组织 + 包管理器 + lockfile 内容 SHA256 前缀 + Node 主版本(及可选
.nvmrc) 维护可复用的node_modules缓存(cache_hit/cache_miss日志);缓存根目录SHIPYARD_BUILD_DEPS_CACHE_PATH(默认系统临时目录下shipyard-build-deps-cache);总占用上限SHIPYARD_BUILD_DEPS_CACHE_MAX_BYTES或SHIPYARD_BUILD_DEPS_CACHE_MAX_MB(默认约 5GiB),超出后按指纹目录 LRU(mtime) 淘汰。构建 workdir 仍为每次build-<deploymentId>,结束后finally清理。 - Docker 构建(opt-in):
SHIPYARD_BUILD_USE_DOCKER=true且 Worker 为 Linux 时,install / lint / test / build 在docker run容器内执行(工作目录挂载到容器/workspace);镜像SHIPYARD_BUILD_DOCKER_IMAGE(默认node:20-bookworm)。git clone 仍在宿主执行。宿主机需可用dockerCLI 与镜像拉取权限。非 Linux 平台开启该开关时 记录告警并回退本机 child_process。 - DeployWorker:SSH 连通后
[precheck](Linux:bash/rsync/按条件nginx与 SSR 时node+pm2;macOS 至少bash/rsync,SSR 时检查pm2/node)。若缺少node,日志中会提示检查 login shell、PATH、nvm(不强制安装 nvm)。SSR 预览健康检查 HTTP 路径可在项目 Pipeline 中配置previewHealthCheckPath(默认/)。常规 Linux 站点 Nginx 配置写入采用 临时文件 + 原子rename,与预览片段策略一致。SSH/rsync 失败仍附带code/errno(若有)。 - 产物清理:已在构建成功后按
artifactRetention自动执行(见 §2)。
| Worker 环境 | SHIPYARD_BUILD_USE_DOCKER=true 行为 |
|---|---|
| Linux(含多数生产 Worker) | 使用 docker run 在容器内执行构建相关命令(需已安装 Docker CLI 且 daemon 可用) |
| macOS / Windows | 不支持容器路径:启动时 warn,构建仍用本机 child_process |
Linux 无 Docker / docker 调用失败 |
构建步骤 失败(与显式开启的预期一致) |
Docker rootless 与卷(运维):若使用 Rootless Docker,请让运行 Worker 的同一用户能访问 daemon(常见做法:export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock,或 docker context use rootless)。构建目录为宿主 /tmp/build-<deploymentId> 绑定挂载到容器 /workspace;依赖缓存目录在宿主 SHIPYARD_BUILD_DEPS_CACHE_PATH(或默认临时目录下 shipyard-build-deps-cache),由 Worker 进程在宿主侧读写(与容器内 node_modules 通过挂载目录同步,无需把缓存根再挂进容器)。请保证上述路径对该用户可写、磁盘充足。
Docker 资源与安全(v0.5+):docker run 默认 --network=bridge(保证 registry 访问)、不加 --privileged**。可通过 SHIPYARD_BUILD_DOCKER_CPUS、SHIPYARD_BUILD_DOCKER_MEMORY 限制 CPU/内存;SHIPYARD_BUILD_DOCKER_NETWORK 可选 bridge/host/none/container:<name>;仅在确有需要时设 SHIPYARD_BUILD_DOCKER_PRIVILEGED=true(高危)。构建日志会打印一行 [docker-build] run opts: … 摘要。
Podman(v0.6+,仅文档):Shipyard Worker 调用的是宿主上的 docker CLI(docker run / docker info)。在 Linux 上若使用 Podman 的 Docker 兼容别名(如 docker → podman),需自行保证 docker info / docker run 语义与 Docker 足够接近(根挂载、网络、卷行为可能与 Docker 有差异)。官方仍以 Docker 为对照测试目标;Podman 环境请充分自测后再用于生产构建。
依赖缓存淘汰顺序(v0.5+):若配置 SHIPYARD_BUILD_DEPS_CACHE_MAX_AGE_DAYS,在每次写入缓存后 先 按指纹目录 mtime 删除过期项(日志 cache_evict_ttl),再 若配置 SHIPYARD_BUILD_DEPS_CACHE_ORG_MAX_MB(或 _MAX_BYTES)则对该 组织 子树做 LRU,最后 对全局总占用做 LRU(日志 cache_evict / cache_evict_org)。
依赖缓存并发(v0.6+):多个 Worker 进程或 同一进程内 多并发构建 Job 若共享 同一 SHIPYARD_BUILD_DEPS_CACHE_PATH,对 淘汰路径(删除指纹目录)在缓存根下使用 .shipyard-deps-evict.lock 跨进程文件锁串行化,降低并发 rmSync 竞态;向 workdir 复制 node_modules 仍可与淘汰以外的步骤并行。若长时间无法获取锁,当次淘汰会跳过并记录 evict_lock_acquire_failed。
| 能力 | 前置条件 | 说明 |
|---|---|---|
direct / rolling、多机 |
SSH;EnvironmentServer 多行 |
按 sortOrder 串行 rsync;primaryServerId 或第一台写 Nginx/域名 |
blue_green(静态) |
Linux + 域名 | 槽位目录 .shipyard-bg0 / .shipyard-bg1,切换站点 Nginx root;健康失败回指旧槽 |
blue_green(SSR) |
Linux + 域名 | 双槽目录 .shipyard-bg0/1、稳定本地端口、PM2 名 sh-env-<slug>-<env>-bg*,Nginx 反代切换;外网健康与 Prometheus 通过后再摘除旧槽;多机时仅入口机执行(与静态蓝绿一致) |
canary |
SSH;Linux 入口机;nginxCanaryPath |
split_clients(默认):nginxCanaryStableUpstream / nginxCanaryCandidateUpstream + canaryPercent,proxy_pass http://$shipyard_canary_pool;。upstream_weight:nginxCanaryTemplate、nginxCanaryUpstreamName、nginxCanaryStableBackend / nginxCanaryCandidateBackend(host:port)+ 百分比权重。手写:nginxCanaryBody 覆盖生成。nginx -t 失败恢复备份。详见 docs/runbooks/canary-nginx.md |
| Prometheus 门禁 | gates.prometheus.queryUrl |
GET 后解析 JSON 向量样本;与通知出站相同的 SSRF 校验 |
| pre/post hooks | SSH | 在入口机 deployPath 下 timeout 120 bash -lc … |
| Kubernetes | 组织「Kubernetes 集群」+ 流水线开启镜像推送 | kubectl set image + rollout status;可选 rolloutTimeoutSeconds(默认 600s);strategy: rolling 时可配 rollingUpdateMaxSurge / MaxUnavailable(set image 前 strategic patch)。不支持 canary / blue_green。凭据见 docs/adr/0001-kubernetes-secrets-and-deploy-worker.md;GitOps 与 patch 冲突见 顺架构需求规格。 |
object_storage(S3) |
Worker 安装 aws CLI;strategy 仅 direct |
解压构建产物后 aws s3 sync 至 objectStorage.bucket/prefix;可选 credentialsEncrypted(解密 JSON 含 accessKeyId/secretAccessKey),否则用环境默认凭证链。见 docs/runbooks/object-storage-s3.md |
| 特性开关 | — | 组织级、项目级或 环境级(GET/POST .../feature-flags?projectSlug=&environmentName=)FeatureFlag CRUD,与部署路径解耦 |
验收:未配置 releaseConfig 时行为与旧版单服务器直连一致。迁移会为每个已有环境插入一条 EnvironmentServer 指向原 serverId。
Stretch / 仅运维文档:完整 GitOps reconcile、多区域 HA、影子流量(Nginx mirror / Mesh)不纳入产品内建路径,占位说明见 docs/runbooks/gitops-shadow-traffic.md。
拆分说明:CI 多 URL 只读探测 与 实例 API 版本自检脚本 分文档维护,见 docs/self-hosted-git.md(含 GIT_SMOKE_URLS、GIT_SMOKE_BASE_URL 与 scripts/probe-git-api-version.mjs)。
| 平台 | 建议自测项 | 说明 | 参考文档 | 版本 / 已知问题 |
|---|---|---|---|---|
| GitLab | Webhook merge_requests_events、MR API 评论 |
自建版本与 gitlab.com 字段可能略有差异 | Webhooks · GitLab Docs | 建议 ≥ 15.x;字段差异见官方 Webhook 文档 |
| Gitea | pull_request 事件、PR 评论 API |
需在 GitConnection.baseUrl 填实例根 URL |
Webhooks · Gitea Docs | 建议 ≥ 1.21;issue 可在仓库 Issues 检索 gitea |
| Gitee | merge_request_hooks |
企业版与公有云文档路径请以实例文档为准 | WebHook 说明 · Gitee 帮助 | 企业版以实例文档为准 |