@wdyy/skills 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +31 -40
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -55
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +9 -5
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +14 -2
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +60 -109
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +151 -277
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +182 -576
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -5
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
- package/README.md +4 -2
- package/lib/wdyy-cli.js +3 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "wdyy-deployment-standard",
|
|
10
|
-
"description": "基于标准五段根 .env,为无 CI/CD
|
|
11
|
-
"files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/generate-deployment-files.mjs", "scripts/generate-deployment-files.test.mjs", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.
|
|
10
|
+
"description": "基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端带版本双 Docker 镜像、本地 deploy.sh build 导出包、人工上传,以及服务器直接宿主机端口的交互式运行、替换和停止;不使用蓝绿部署、宿主机 Nginx 或数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。",
|
|
11
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/generate-deployment-files.mjs", "scripts/generate-deployment-files.test.mjs", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.yml", "templates/dockerignore.template"]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"name": "wdyy-api-standard",
|
|
@@ -1,72 +1,63 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wdyy-deployment-standard
|
|
3
|
-
description: 基于标准五段根 .env,为无 CI/CD
|
|
3
|
+
description: 基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端带版本双 Docker 镜像、本地 deploy.sh build 导出包、人工上传,以及服务器直接宿主机端口的交互式运行、替换和停止;不使用蓝绿部署、宿主机 Nginx 或数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 企业部署规范
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev
|
|
10
|
+
把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地项目根目录用 `./deploy.sh build` 生成带版本号的前后端镜像和完整 `deploy/`;工程师人工上传;服务器项目根目录无参数运行 `./deploy.sh`,按菜单直接运行或替换固定服务。
|
|
11
11
|
|
|
12
12
|
## 使用边界
|
|
13
13
|
|
|
14
|
-
1. 先读取目标项目真实的 pnpm scripts、前后端构建输出、健康/版本接口、Dockerfile
|
|
15
|
-
2. 完整读取 [Linux 部署规则](reference/linux-deployment-rules.md),运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 确定性生成受版本控制文件,并向 `.env` 与 `.env.example`
|
|
16
|
-
3.
|
|
17
|
-
4. 工程师人工上传 `deploy/`
|
|
14
|
+
1. 先读取目标项目真实的 pnpm scripts、前后端构建输出、健康/版本接口、Dockerfile 和 `.env`。
|
|
15
|
+
2. 完整读取 [Linux 部署规则](reference/linux-deployment-rules.md),运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 确定性生成受版本控制文件,并向 `.env` 与 `.env.example` 的唯一“部署”段追加同名配置;既有文件不一致时先审查差异,只有工程师确认后才使用 `--force`。
|
|
16
|
+
3. 应用监听和宿主机直接暴露端口只使用 `FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT`;不得增加重复容器端口或健康 URL 配置。
|
|
17
|
+
4. 工程师人工上传 `deploy/` 全部内容作为服务器项目根目录;脚本不得上传、拉取代码、安装依赖或修改宿主机 Nginx。
|
|
18
18
|
5. 生成后先运行生成器的 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和全部行为测试。
|
|
19
19
|
|
|
20
20
|
## 固定契约
|
|
21
21
|
|
|
22
22
|
- 项目名只允许小写字母、数字和下划线;服务器部署根由上传后的 `deploy.sh` 物理路径确定。
|
|
23
|
-
-
|
|
24
|
-
- Compose
|
|
25
|
-
-
|
|
26
|
-
-
|
|
23
|
+
- 镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` 和 `<项目名称>_backend:<YYYYMMDD-NNN>`。
|
|
24
|
+
- Compose 只允许 frontend、backend 两个服务,不设置 `container_name`,不包含颜色、切流或回滚状态。
|
|
25
|
+
- frontend 直接映射 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 直接映射 `${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}`。
|
|
26
|
+
- 两个容器只把服务器项目根 `./logs` 挂载到 `/app/logs`;日志文件直接位于 `logs/`,不得创建服务、实例、颜色或日期子目录。
|
|
27
|
+
- 服务器部署操作追加写入 `./logs/deploy.log`;前端 Nginx 写入 `frontend-access.log` 和 `frontend-error.log`;后端遵循 `wdyy-logging-standard`。
|
|
27
28
|
- 根 `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
|
|
28
|
-
- `.env.example`
|
|
29
|
-
- 环境文件固定按“前端、后端、数据库、API
|
|
29
|
+
- `.env.example` 内置 `FRONTEND_BASE_IMAGE=nginx:stable` 与 `BACKEND_BASE_IMAGE=node:24-alpine3.24`;`.env` 可显式覆盖。
|
|
30
|
+
- 环境文件固定按“前端、后端、数据库、API、部署”排列;部署段只包含 `PROJECT_NAME`、`DOCKER_BIND_IP`、`FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE`。
|
|
30
31
|
- 发布链路不接受数据库迁移参数,也不打包、校验或执行迁移文件。
|
|
31
|
-
- 相同输入生成的 deploy.sh、Compose、Nginx 配置必须字节一致;版本指针、镜像归档和 manifest 可随发布变化。
|
|
32
32
|
|
|
33
33
|
## 工作流
|
|
34
34
|
|
|
35
35
|
1. 使用 `pnpm dev` 完成开发测试。
|
|
36
|
-
2.
|
|
37
|
-
3. 依次通过测试、lint
|
|
38
|
-
4. 人工把 `deploy/`
|
|
39
|
-
5.
|
|
40
|
-
6.
|
|
41
|
-
|
|
42
|
-
## Nginx 当前项目边界
|
|
43
|
-
|
|
44
|
-
- 每个项目只写自身 `nginx/site.conf` 和 `nginx/active-upstreams.conf`。
|
|
45
|
-
- upstream 名称由严格项目名确定,不接受自由文本配置。
|
|
46
|
-
- 安装候选配置后运行 `nginx -t` 和 reload;失败时恢复本项目旧文件。
|
|
47
|
-
- 不扫描、校验或修改其他项目配置、端口或 Nginx include。
|
|
36
|
+
2. 在本地项目根目录执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
|
|
37
|
+
3. 依次通过测试、lint、类型检查、构建、双镜像标签检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
|
|
38
|
+
4. 人工把 `deploy/` 全部内容上传为服务器项目根目录;生产值不得通过命令行传入。
|
|
39
|
+
5. 在服务器项目根目录无参数执行 `./deploy.sh`,选择:运行或替换前端、运行或替换后端、运行或替换前后端、停止。
|
|
40
|
+
6. 运行或替换会载入所选服务的目标版本镜像、强制重建并健康检查;停止会同时停止 frontend 和 backend。
|
|
48
41
|
|
|
49
42
|
## 禁止事项
|
|
50
43
|
|
|
51
|
-
-
|
|
52
|
-
-
|
|
44
|
+
- 不得使用蓝绿、滚动切流、活动/历史颜色、宿主机 Nginx、自动回滚或旧版 start/replace/restart/remove/status/rollback 命令。
|
|
45
|
+
- 不得接受人工 build 版本参数,不得扫描版本目录替代发布账本或 `release.env`。
|
|
46
|
+
- 不得使用 Bash `source`/`eval` 解析 `.env` 或版本指针。
|
|
53
47
|
- 不得使用镜像 fallback、固定 `/srv`、固定 URL/IP/端口、PM2、systemd 或自动安装。
|
|
54
|
-
- 不得重新引入 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST`
|
|
55
|
-
- 不得先停止活动颜色再启动新颜色,不得分别切换前后端,不得在验证前 reload。
|
|
48
|
+
- 不得重新引入 `PROJECT_HTTP_PORT`、`FRONTEND_BLUE_PORT`、`FRONTEND_GREEN_PORT`、`BACKEND_BLUE_PORT`、`BACKEND_GREEN_PORT`、`FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST` 或任何 `*_HEALTH_URL`、`*_VERSION_URL`。
|
|
56
49
|
- 不得自动执行 psql、迁移脚本或数据库回滚。
|
|
57
|
-
-
|
|
58
|
-
- 不得以字符串关键字存在代替命令行为、失败恢复和资源边界测试。
|
|
50
|
+
- 不得以字符串关键字存在代替命令行为、失败传播和资源边界测试。
|
|
59
51
|
|
|
60
52
|
## Verification
|
|
61
53
|
|
|
62
|
-
- [ ]
|
|
63
|
-
- [ ]
|
|
64
|
-
- [ ]
|
|
65
|
-
- [ ]
|
|
66
|
-
- [ ]
|
|
67
|
-
- [ ]
|
|
68
|
-
- [ ]
|
|
54
|
+
- [ ] 两个镜像仓库名、版本标签和 OCI version label 精确匹配发布版本。
|
|
55
|
+
- [ ] Compose 只含 frontend、backend,使用根 `.env`、直接宿主机端口和 `../../logs:/app/logs`。
|
|
56
|
+
- [ ] 服务器无参数菜单只有四项;独立和同时替换不会操作未选择服务;停止同时停止两服务。
|
|
57
|
+
- [ ] `.env` 与 `.env.example` 五段、变量顺序一致,无蓝绿、Nginx、重复端口或日志目录配置。
|
|
58
|
+
- [ ] 部署、前端和后端日志直接位于项目根 `logs/`,容器替换或停止后仍保留。
|
|
59
|
+
- [ ] 不存在数据库迁移参数、迁移包内容、上传命令或数据库执行路径。
|
|
60
|
+
- [ ] 连续两次构建的 deploy.sh 和 Compose 一致,失败不写发布记录、不暴露半成品。
|
|
69
61
|
- [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
|
|
70
|
-
- [ ] 替换或删除容器后,项目根 `logs/` 内容仍保留。
|
|
71
62
|
|
|
72
|
-
|
|
63
|
+
任一步失败必须明确报告具体阶段并返回非零;不得静默跳过、伪造成功或删除失败证据。
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "wdyy-deployment-standard"
|
|
3
|
-
short_description: "Build
|
|
4
|
-
default_prompt: "Use $wdyy-deployment-standard to generate and validate
|
|
3
|
+
short_description: "Build and operate direct dual-image deployments"
|
|
4
|
+
default_prompt: "Use $wdyy-deployment-standard to generate and validate a versioned dual-image manual deployment with direct host ports and an interactive server menu."
|
|
@@ -9,17 +9,16 @@ deploy.sh
|
|
|
9
9
|
.dockerignore
|
|
10
10
|
src/frontend/Dockerfile
|
|
11
11
|
src/backend/Dockerfile
|
|
12
|
-
scripts/deployment/docker-compose.
|
|
12
|
+
scripts/deployment/docker-compose.yml
|
|
13
13
|
scripts/deployment/frontend-container.conf.template
|
|
14
|
-
scripts/deployment/nginx-site.conf
|
|
15
14
|
.env.example
|
|
16
15
|
```
|
|
17
16
|
|
|
18
|
-
这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates
|
|
17
|
+
这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates 是唯一源。既有差异默认失败,工程师审查确认后才允许 `--force`。生成后执行 `--check`。相同输入连续构建时 deploy.sh 与 Compose 必须字节一致。
|
|
19
18
|
|
|
20
19
|
## 根 .env
|
|
21
20
|
|
|
22
|
-
根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API
|
|
21
|
+
根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API、部署”排列。部署前至少要求:
|
|
23
22
|
|
|
24
23
|
```dotenv
|
|
25
24
|
FRONTEND_URL=0.0.0.0
|
|
@@ -33,33 +32,25 @@ DB_PASSWORD=
|
|
|
33
32
|
DB_NAME=
|
|
34
33
|
DB_SCHEMA=
|
|
35
34
|
PROJECT_NAME=example_project
|
|
36
|
-
|
|
37
|
-
DOCKER_BIND_IP=127.0.0.1
|
|
38
|
-
FRONTEND_BLUE_PORT=19091
|
|
39
|
-
FRONTEND_GREEN_PORT=19092
|
|
40
|
-
BACKEND_BLUE_PORT=19093
|
|
41
|
-
BACKEND_GREEN_PORT=19094
|
|
35
|
+
DOCKER_BIND_IP=0.0.0.0
|
|
42
36
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
43
37
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
44
38
|
```
|
|
45
39
|
|
|
46
|
-
`
|
|
40
|
+
`FRONTEND_PORT`、`BACKEND_PORT` 同时是容器监听端口与宿主机直接暴露端口,二者必须不同且位于 1–65535。`PROJECT_NAME` 只允许 `^[a-z][a-z0-9_]*$`。`.env` 权限必须为 `0600` 或只读的 `0400`。脚本按数据解析环境文件,不得 `source` 或 `eval`。
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`.env` 权限必须为 `0600` 或只读的 `0400`。部署脚本只把明确的部署键载入自身环境;数据库密码、JWT 等其他应用键保留在文件中供 Compose 注入容器,不得改变脚本的 `PATH`、Shell 选项或命令行为。
|
|
51
|
-
|
|
52
|
-
可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`DEPLOY_CONFIG_DIR`;未设置时只能使用与目标项目真实结构一致且已验证的标准路径。
|
|
42
|
+
可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`DOCKERIGNORE_FILE`、`DEPLOY_CONFIG_DIR` 和 `COMPOSE_SOURCE`;它们不属于标准 `.env.example`,只在目标项目确有非标准结构且已确认时使用。
|
|
53
43
|
|
|
54
44
|
## Docker 与日志
|
|
55
45
|
|
|
56
46
|
- 前端镜像:`${PROJECT_NAME}_frontend:${RELEASE_VERSION}`。
|
|
57
47
|
- 后端镜像:`${PROJECT_NAME}_backend:${RELEASE_VERSION}`。
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
48
|
+
- Compose project 使用 `$PROJECT_NAME`,只定义 frontend、backend,不设置 `container_name`。
|
|
49
|
+
- frontend 端口映射为 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 同理使用 `BACKEND_PORT`。
|
|
50
|
+
- 两个服务均使用 `../../.env`,且只挂载 `../../logs:/app/logs`。
|
|
51
|
+
- 前端 Nginx access/error 直接写 `/app/logs/frontend-access.log`、`/app/logs/frontend-error.log`。
|
|
52
|
+
- 后端日志直接写 `/app/logs`;服务器菜单操作追加到部署根 `logs/deploy.log`。
|
|
53
|
+
- `.dockerignore` 必须排除 `.env`、秘密、deploy、logs、Git、依赖缓存和虚拟环境。
|
|
63
54
|
|
|
64
55
|
## 发布账本与本地包
|
|
65
56
|
|
|
@@ -73,7 +64,7 @@ BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
|
73
64
|
| 20260808-001 | 2026-08-08 09:30:00 +08:00 |
|
|
74
65
|
```
|
|
75
66
|
|
|
76
|
-
build
|
|
67
|
+
build 使用项目级原子锁,严格验证账本后生成当天下一序号。全部门禁、双镜像、包结构和确定性校验通过后才写账本。
|
|
77
68
|
|
|
78
69
|
```text
|
|
79
70
|
deploy/
|
|
@@ -84,44 +75,25 @@ deploy/
|
|
|
84
75
|
├── frontend-image.tar
|
|
85
76
|
├── backend-image.tar
|
|
86
77
|
├── manifest.sha256
|
|
87
|
-
|
|
88
|
-
├── nginx/site.conf
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
`release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件,禁止符号链接、硬链接秘密、绝对路径和 `..`。根 `.env` 可上传,但版本目录与镜像不得包含它。
|
|
92
|
-
|
|
93
|
-
## 服务器部署和 Nginx
|
|
94
|
-
|
|
95
|
-
上传目标由工程师确定;脚本通过自身物理路径确定部署根,不依赖调用者当前目录。
|
|
96
|
-
|
|
97
|
-
共享 Nginx 主配置由运维预先 include `/xxzx/home/*/nginx/site.conf`。项目脚本不得写 `/etc/nginx` 或其他项目,只维护:
|
|
98
|
-
|
|
99
|
-
```text
|
|
100
|
-
<project-root>/nginx/site.conf
|
|
101
|
-
<project-root>/nginx/active-upstreams.conf
|
|
78
|
+
└── docker/docker-compose.yml
|
|
102
79
|
```
|
|
103
80
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
## 命令
|
|
81
|
+
`release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件。根 `.env` 可上传,但版本目录与镜像不得包含它。
|
|
107
82
|
|
|
108
|
-
|
|
109
|
-
- `./deploy.sh replace`:只允许已有活动状态且 release.env 指向不同新版本。
|
|
110
|
-
- `./deploy.sh restart`:重新创建并验证当前颜色的两个容器,不改变流量颜色和版本。
|
|
111
|
-
- `./deploy.sh stop`:停止当前颜色的两个容器,保留所有文件和状态。
|
|
112
|
-
- `./deploy.sh remove`:删除 Compose project 下四个容器及网络,并删除两个精确项目镜像仓库的全部标签;保留日志、包、状态和 Nginx 文件。
|
|
113
|
-
- `./deploy.sh status`:报告 active/previous 和四服务状态。
|
|
114
|
-
- `./deploy.sh rollback <version>`:只允许 previous 中已保留且健康的成对容器。
|
|
83
|
+
## 服务器菜单
|
|
115
84
|
|
|
116
|
-
|
|
85
|
+
工程师把 `deploy/` 内容完整上传为服务器项目根目录,随后无参数执行 `./deploy.sh`:
|
|
117
86
|
|
|
118
|
-
|
|
87
|
+
1. 运行或替换前端:验证包和前端镜像标签,只载入并重建 frontend,验证 `/health` 与 `/version`。
|
|
88
|
+
2. 运行或替换后端:验证包和后端镜像标签,只载入并重建 backend,验证 `/health` 与 `/version`。
|
|
89
|
+
3. 运行或替换前后端:载入并重建两个服务,两个服务全部验证成功后报告成功。
|
|
90
|
+
4. 停止:同时停止 frontend 和 backend,保留容器、镜像、上传文件和日志。
|
|
119
91
|
|
|
120
|
-
|
|
92
|
+
菜单操作使用 `release.env` 指向的版本,不接受版本参数。非法输入必须在 Docker 状态变更前失败。部署根始终由脚本物理路径确定。
|
|
121
93
|
|
|
122
|
-
##
|
|
94
|
+
## 数据库和失败边界
|
|
123
95
|
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
96
|
+
- 部署 Skill 不接受数据库迁移参数,不打包、校验、提示或执行迁移文件。
|
|
97
|
+
- 固定服务替换允许短暂停机;失败时返回非零并记录阶段,不自动回滚或伪造成功。
|
|
98
|
+
- 脚本不得上传文件、拉取代码、安装软件、修改宿主机 Nginx 或操作其他项目资源。
|
|
99
|
+
- 旧蓝绿容器、状态和宿主机 Nginx 配置必须由工程师按旧部署实际状态单独迁移,新脚本不得猜测并自动删除。
|
|
@@ -10,9 +10,8 @@ const mappings = [
|
|
|
10
10
|
['dockerignore.template', '.dockerignore', 0o644],
|
|
11
11
|
['frontend.Dockerfile.template', 'src/frontend/Dockerfile', 0o644],
|
|
12
12
|
['Dockerfile.template', 'src/backend/Dockerfile', 0o644],
|
|
13
|
-
['docker-compose.
|
|
13
|
+
['docker-compose.yml', 'scripts/deployment/docker-compose.yml', 0o644],
|
|
14
14
|
['frontend-container.conf.template', 'scripts/deployment/frontend-container.conf.template', 0o644],
|
|
15
|
-
['nginx-upstream.template.conf', 'scripts/deployment/nginx-site.conf', 0o644],
|
|
16
15
|
];
|
|
17
16
|
|
|
18
17
|
const sectionNames = ['前端', '后端', '数据库', 'API', '部署'];
|
|
@@ -22,11 +21,16 @@ const baseKeys = new Map([
|
|
|
22
21
|
['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
|
|
23
22
|
]);
|
|
24
23
|
const deploymentKeys = [
|
|
25
|
-
'PROJECT_NAME', '
|
|
26
|
-
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
24
|
+
'PROJECT_NAME', 'DOCKER_BIND_IP',
|
|
27
25
|
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
28
26
|
];
|
|
29
27
|
|
|
28
|
+
const removedDeploymentKeys = new Set([
|
|
29
|
+
'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT',
|
|
30
|
+
'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT',
|
|
31
|
+
'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR',
|
|
32
|
+
]);
|
|
33
|
+
|
|
30
34
|
function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
|
|
31
35
|
const normalized = text.replaceAll('\r\n', '\n');
|
|
32
36
|
const sections = new Map(sectionNames.map((name) => [name, []]));
|
|
@@ -47,7 +51,7 @@ function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults,
|
|
|
47
51
|
const entry = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
48
52
|
if (!entry) throw new Error(`${relativeTarget}:${index + 1} must use KEY=VALUE syntax`);
|
|
49
53
|
if (seenKeys.has(entry[1])) throw new Error(`${relativeTarget} contains duplicate key: ${entry[1]}`);
|
|
50
|
-
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(entry[1]) || /_(?:HEALTH|VERSION)_URL$/.test(entry[1])) {
|
|
54
|
+
if (removedDeploymentKeys.has(entry[1]) || ['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(entry[1]) || /_(?:HEALTH|VERSION)_URL$/.test(entry[1])) {
|
|
51
55
|
throw new Error(`${relativeTarget} contains legacy duplicate configuration: ${entry[1]}`);
|
|
52
56
|
}
|
|
53
57
|
seenKeys.add(entry[1]);
|
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -34,11 +34,14 @@ test('确定性生成全部标准文件并通过 check', async () => {
|
|
|
34
34
|
const checked = run(root, '--check');
|
|
35
35
|
assert.equal(checked.status, 0, checked.stderr);
|
|
36
36
|
assert.match(checked.stdout, /verified deterministic deployment files/);
|
|
37
|
-
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /
|
|
37
|
+
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /server_menu/);
|
|
38
|
+
assert.match(await readFile(join(root, 'scripts/deployment/docker-compose.yml'), 'utf8'), /^ frontend:[\s\S]*^ backend:/m);
|
|
39
|
+
await assert.rejects(stat(join(root, 'scripts/deployment/docker-compose.blue-green.yml')));
|
|
38
40
|
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
39
41
|
const example = await readFile(join(root, '.env.example'), 'utf8');
|
|
40
42
|
assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
|
|
41
|
-
assert.match(environment, /^# 部署\nPROJECT_NAME=\
|
|
43
|
+
assert.match(environment, /^# 部署\nPROJECT_NAME=\nDOCKER_BIND_IP=/m);
|
|
44
|
+
assert.doesNotMatch(environment, /PROJECT_HTTP_PORT|BLUE_PORT|GREEN_PORT/);
|
|
42
45
|
assert.match(environment, /^FRONTEND_BASE_IMAGE=nginx:stable$/m);
|
|
43
46
|
assert.match(environment, /^BACKEND_BASE_IMAGE=node:24-alpine3\.24$/m);
|
|
44
47
|
assert.equal(example, environment);
|
|
@@ -106,3 +109,12 @@ test('重复或冲突参数被明确拒绝', async () => {
|
|
|
106
109
|
assert.notEqual(duplicateTarget.status, 0);
|
|
107
110
|
assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
|
|
108
111
|
});
|
|
112
|
+
|
|
113
|
+
test('旧蓝绿和宿主机 Nginx 配置键被明确拒绝', async () => {
|
|
114
|
+
const root = await projectRoot();
|
|
115
|
+
const envPath = join(root, '.env');
|
|
116
|
+
await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 部署\n', '# 部署\nPROJECT_HTTP_PORT=9099\n'), { mode: 0o600 });
|
|
117
|
+
const generated = run(root, '--write');
|
|
118
|
+
assert.notEqual(generated.status, 0);
|
|
119
|
+
assert.match(generated.stderr, /legacy duplicate configuration/);
|
|
120
|
+
});
|
|
@@ -3,21 +3,21 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
|
4
4
|
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
|
|
6
|
-
const [deployPath,
|
|
7
|
-
if (!deployPath || !
|
|
8
|
-
throw new Error('Pass deploy.sh,
|
|
6
|
+
const [deployPath, composePath, packageRoot] = process.argv.slice(2);
|
|
7
|
+
if (!deployPath || !composePath || !packageRoot || process.argv.length !== 5) {
|
|
8
|
+
throw new Error('Pass deploy.sh, Compose file and generated deploy directory paths');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const [deploy,
|
|
12
|
-
readFile(deployPath, 'utf8'),
|
|
13
|
-
readFile(nginxPath, 'utf8'),
|
|
14
|
-
readFile(composePath, 'utf8'),
|
|
15
|
-
]);
|
|
11
|
+
const [deploy, compose] = await Promise.all([readFile(deployPath, 'utf8'), readFile(composePath, 'utf8')]);
|
|
16
12
|
|
|
17
13
|
function requirePattern(text, pattern, message) {
|
|
18
14
|
if (!pattern.test(text)) throw new Error(message);
|
|
19
15
|
}
|
|
20
16
|
|
|
17
|
+
function assertDeepEqual(actual, expected, message) {
|
|
18
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
21
|
function parseDotenv(text) {
|
|
22
22
|
const values = new Map();
|
|
23
23
|
for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
|
|
@@ -26,141 +26,98 @@ function parseDotenv(text) {
|
|
|
26
26
|
if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
|
|
27
27
|
if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
|
|
28
28
|
let value = match[2];
|
|
29
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
30
|
-
|
|
31
|
-
} else if (value.startsWith('"') || value.startsWith("'")) {
|
|
32
|
-
throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
33
|
-
}
|
|
29
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
30
|
+
else if (value.startsWith('"') || value.startsWith("'")) throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
34
31
|
values.set(match[1], value);
|
|
35
32
|
}
|
|
36
33
|
return values;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/
|
|
54
|
-
/
|
|
55
|
-
/pnpm
|
|
56
|
-
/pnpm
|
|
57
|
-
/pnpm
|
|
58
|
-
/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/endpoint_url "\$DOCKER_BIND_IP"/,
|
|
64
|
-
/flock -x 9/,
|
|
65
|
-
/Nginx reload failed; old project configuration restored/,
|
|
66
|
-
/docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
|
|
67
|
-
/\$1 == frontend \|\| \$1 == backend/,
|
|
68
|
-
];
|
|
69
|
-
for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
|
|
70
|
-
if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
|
|
71
|
-
throw new Error('Environment and state files must be parsed as data, not sourced');
|
|
72
|
-
}
|
|
36
|
+
for (const [pattern, message] of [
|
|
37
|
+
[/if \[\[ "\$#" -eq 0 \]\]; then server_menu/, 'No-argument execution must open the server menu'],
|
|
38
|
+
[/elif \[\[ "\$#" -eq 1 && "\$1" == build \]\]; then build_release/, 'build must be the only command-line operation'],
|
|
39
|
+
[/'1\. 运行或替换前端'/, 'Missing frontend menu item'],
|
|
40
|
+
[/'2\. 运行或替换后端'/, 'Missing backend menu item'],
|
|
41
|
+
[/'3\. 运行或替换前后端'/, 'Missing dual-service menu item'],
|
|
42
|
+
[/'4\. 停止'/, 'Missing stop menu item'],
|
|
43
|
+
[/docker save -o "\$version_root\/frontend-image\.tar"/, 'Missing frontend image export'],
|
|
44
|
+
[/docker save -o "\$version_root\/backend-image\.tar"/, 'Missing backend image export'],
|
|
45
|
+
[/frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/, 'Missing versioned frontend image name'],
|
|
46
|
+
[/backend_image="\$\{PROJECT_NAME\}_backend:\$version"/, 'Missing versioned backend image name'],
|
|
47
|
+
[/compose up -d --force-recreate --no-deps frontend/, 'Frontend operation must only recreate frontend'],
|
|
48
|
+
[/compose up -d --force-recreate --no-deps backend/, 'Backend operation must only recreate backend'],
|
|
49
|
+
[/compose up -d --force-recreate frontend backend/, 'Combined operation must recreate both services'],
|
|
50
|
+
[/compose stop frontend backend/, 'Stop operation must stop both services'],
|
|
51
|
+
[/logs\/deploy\.log/, 'Server operations must write deploy.log'],
|
|
52
|
+
[/pnpm test/, 'Missing test release gate'],
|
|
53
|
+
[/pnpm lint/, 'Missing lint release gate'],
|
|
54
|
+
[/pnpm typecheck/, 'Missing typecheck release gate'],
|
|
55
|
+
[/pnpm build/, 'Missing build release gate'],
|
|
56
|
+
]) requirePattern(deploy, pattern, message);
|
|
57
|
+
|
|
58
|
+
if (/^\s*(?:start|replace|restart|remove|status|rollback)\)/m.test(deploy)) throw new Error('Legacy server commands are forbidden');
|
|
59
|
+
if (/\bsource\s+["']?[^\n]*\.env/.test(deploy)) throw new Error('Environment files must be parsed as data, not sourced');
|
|
73
60
|
if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
|
|
74
61
|
if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
|
|
75
62
|
if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
|
|
76
|
-
if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${
|
|
63
|
+
if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
|
|
64
|
+
if (/nginx\s+-(?:t|s)|active-upstreams|ACTIVE_COLOR|PREVIOUS_COLOR/.test(deploy)) throw new Error('Host Nginx and blue-green state are forbidden');
|
|
77
65
|
|
|
78
66
|
const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
|
|
79
|
-
assertDeepEqual(serviceNames, ['frontend
|
|
67
|
+
assertDeepEqual(serviceNames, ['frontend', 'backend'], 'Compose must define exactly two ordered services');
|
|
80
68
|
if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
|
|
81
|
-
if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !==
|
|
82
|
-
if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !==
|
|
83
|
-
if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !==
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
if (
|
|
87
|
-
if (/container_name:/.test(compose)) throw new Error('
|
|
88
|
-
if (/
|
|
89
|
-
throw new Error('Compose contains legacy duplicate URL or port configuration');
|
|
90
|
-
}
|
|
91
|
-
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
|
|
92
|
-
if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
|
|
96
|
-
if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
|
|
97
|
-
}
|
|
98
|
-
if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx)) {
|
|
99
|
-
throw new Error('Nginx template must expose deterministic project and port markers');
|
|
100
|
-
}
|
|
101
|
-
if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
|
|
69
|
+
if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Frontend must require FRONTEND_IMAGE');
|
|
70
|
+
if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Backend must require BACKEND_IMAGE');
|
|
71
|
+
if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 2) throw new Error('Both services must use root .env');
|
|
72
|
+
if ((compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? []).length !== 2) throw new Error('Both services must mount flat project-root logs');
|
|
73
|
+
if (!compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"')) throw new Error('Frontend must directly expose FRONTEND_PORT');
|
|
74
|
+
if (!compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"')) throw new Error('Backend must directly expose BACKEND_PORT');
|
|
75
|
+
if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)|container_name:/.test(compose)) throw new Error('Configurable, nested logs or container_name are forbidden');
|
|
76
|
+
if (/frontend-(?:blue|green)|backend-(?:blue|green)|PROJECT_HTTP_PORT|_(?:BLUE|GREEN)_PORT|nginx\/site/.test(compose)) throw new Error('Blue-green or host Nginx Compose configuration is forbidden');
|
|
102
77
|
|
|
103
78
|
const rootEnvPath = join(packageRoot, '.env');
|
|
104
79
|
const rootEnvStat = await stat(rootEnvPath);
|
|
105
|
-
if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0)
|
|
106
|
-
throw new Error('Root .env must be a private owner-readable file');
|
|
107
|
-
}
|
|
80
|
+
if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) throw new Error('Root .env must be a private owner-readable file');
|
|
108
81
|
const rootEnvText = await readFile(rootEnvPath, 'utf8');
|
|
109
82
|
const env = parseDotenv(rootEnvText);
|
|
110
83
|
const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
111
84
|
if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
116
|
-
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
117
|
-
];
|
|
118
|
-
for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
85
|
+
for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
86
|
+
if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
87
|
+
}
|
|
119
88
|
for (const key of env.keys()) {
|
|
120
|
-
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
|
|
121
|
-
throw new Error(`Root .env contains
|
|
89
|
+
if (['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
|
|
90
|
+
throw new Error(`Root .env contains removed deployment configuration: ${key}`);
|
|
122
91
|
}
|
|
123
92
|
}
|
|
124
93
|
if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
|
|
125
|
-
for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
|
|
129
|
-
const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
130
|
-
for (const key of allPortKeys) {
|
|
94
|
+
for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
|
|
95
|
+
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
|
|
131
96
|
const port = Number(env.get(key));
|
|
132
97
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
|
|
133
98
|
}
|
|
134
|
-
|
|
135
|
-
const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
|
|
136
|
-
if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
|
|
137
|
-
throw new Error('Project and host blue-green ports must be valid and unique');
|
|
138
|
-
}
|
|
99
|
+
if (env.get('FRONTEND_PORT') === env.get('BACKEND_PORT')) throw new Error('Frontend and backend host ports must differ');
|
|
139
100
|
|
|
140
101
|
const releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
|
|
141
102
|
const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
|
|
142
103
|
if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
|
|
143
104
|
const version = releaseMatch[1];
|
|
144
|
-
|
|
145
|
-
assertDeepEqual((await readdir(packageRoot)).sort(), expectedRootEntries, 'Generated deploy root has unexpected entries');
|
|
105
|
+
assertDeepEqual((await readdir(packageRoot)).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort(), 'Generated deploy root has unexpected entries');
|
|
146
106
|
if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
|
|
147
|
-
if (resolve(
|
|
148
|
-
if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
|
|
107
|
+
if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.yml')) throw new Error('Compose path must be inside the selected version');
|
|
149
108
|
|
|
150
109
|
const versionRoot = join(packageRoot, version);
|
|
151
|
-
const
|
|
152
|
-
for (const path of requiredPaths) {
|
|
110
|
+
for (const path of ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.yml']) {
|
|
153
111
|
const info = await stat(join(versionRoot, path));
|
|
154
112
|
if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
|
|
155
113
|
}
|
|
156
114
|
await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
|
|
115
|
+
await assertMissing(join(versionRoot, 'nginx'), 'Release package must not contain host Nginx files');
|
|
157
116
|
|
|
158
117
|
const versionFiles = (await listFiles(versionRoot)).sort();
|
|
159
118
|
for (const path of versionFiles) {
|
|
160
119
|
const name = basename(path);
|
|
161
|
-
if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
|
|
162
|
-
throw new Error(`Secret file must not enter version directory: ${path}`);
|
|
163
|
-
}
|
|
120
|
+
if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) throw new Error(`Secret file must not enter version directory: ${path}`);
|
|
164
121
|
}
|
|
165
122
|
const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
|
|
166
123
|
const entries = new Map();
|
|
@@ -176,13 +133,7 @@ for (const [path, expected] of entries) {
|
|
|
176
133
|
if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
|
|
177
134
|
}
|
|
178
135
|
|
|
179
|
-
process.stdout.write(`valid dual-image deployment package ${version}\n`);
|
|
180
|
-
|
|
181
|
-
function assertDeepEqual(actual, expected, message) {
|
|
182
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
183
|
-
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
136
|
+
process.stdout.write(`valid direct dual-image deployment package ${version}\n`);
|
|
186
137
|
|
|
187
138
|
async function assertMissing(path, message) {
|
|
188
139
|
try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|