@wdyy/skills 0.1.9 → 0.1.11
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 +1 -1
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +11 -13
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +3 -11
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +11 -5
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +3 -1
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +7 -20
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +18 -94
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +15 -112
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -2
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -1
- package/README.md +1 -1
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "wdyy-deployment-standard",
|
|
10
|
-
"description": "基于标准五段根 .env,为无 CI/CD
|
|
10
|
+
"description": "基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚;部署根由脚本位置确定且不处理数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。",
|
|
11
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.blue-green.yml", "templates/dockerignore.template", "templates/nginx-upstream.template.conf"]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wdyy-deployment-standard
|
|
3
|
-
description: 基于标准五段根 .env,为无 CI/CD
|
|
3
|
+
description: 基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚;部署根由脚本位置确定且不处理数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 企业部署规范
|
|
@@ -19,15 +19,15 @@ description: 基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务
|
|
|
19
19
|
|
|
20
20
|
## 固定契约
|
|
21
21
|
|
|
22
|
-
-
|
|
22
|
+
- 项目名只允许小写字母、数字和下划线;服务器部署根由上传后的 `deploy.sh` 物理路径确定。
|
|
23
23
|
- 两个镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` 和 `<项目名称>_backend:<YYYYMMDD-NNN>`。
|
|
24
24
|
- Compose 必须包含 frontend-blue、backend-blue、frontend-green、backend-green;同一颜色的前后端作为一个切流单元。
|
|
25
25
|
- 宿主机 Nginx 只监听根 `.env` 的 `PROJECT_HTTP_PORT`,`/api/` 代理活动后端,其他请求代理活动前端。
|
|
26
26
|
- 所有容器只把项目根 `./logs` 挂载到 `/app/logs`;日志文件直接位于 `logs/`,不得创建服务、颜色或日期子目录。
|
|
27
27
|
- 根 `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
|
|
28
|
-
- `FRONTEND_BASE_IMAGE` 与 `BACKEND_BASE_IMAGE`
|
|
28
|
+
- `.env.example` 是环境主模板,内置 `FRONTEND_BASE_IMAGE=nginx:stable` 与 `BACKEND_BASE_IMAGE=node:24-alpine3.24`;`.env` 可在生成后显式覆盖。相同标签在不同时间重新构建时可能对应不同基础镜像内容,但已导出的项目成品镜像仍按版本确定部署。
|
|
29
29
|
- 环境文件固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段和已有值,只扩充部署段。
|
|
30
|
-
-
|
|
30
|
+
- 发布链路不接受数据库迁移参数,也不打包、校验或执行迁移文件。
|
|
31
31
|
- 相同输入生成的 deploy.sh、Compose、Nginx 配置必须字节一致;版本指针、镜像归档和 manifest 可随发布变化。
|
|
32
32
|
|
|
33
33
|
## 工作流
|
|
@@ -35,18 +35,16 @@ description: 基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务
|
|
|
35
35
|
1. 使用 `pnpm dev` 完成开发测试。
|
|
36
36
|
2. 执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
|
|
37
37
|
3. 依次通过测试、lint、类型检查、构建、双镜像语义检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
|
|
38
|
-
4. 人工把 `deploy/`
|
|
39
|
-
5.
|
|
40
|
-
6.
|
|
41
|
-
7. 通过 `restart`、`stop`、`status`、`rollback <version>` 管理当前项目;`remove` 只删除本项目容器、网络及两个精确镜像仓库。
|
|
38
|
+
4. 人工把 `deploy/` 全部内容上传到目标部署目录;生产值不得通过命令行传入。
|
|
39
|
+
5. 首次部署执行 `./deploy.sh start`;后续新版本执行 `./deploy.sh replace`。
|
|
40
|
+
6. 通过 `restart`、`stop`、`status`、`rollback <version>` 管理当前项目;`remove` 只删除本项目容器、网络及两个精确镜像仓库。
|
|
42
41
|
|
|
43
|
-
## Nginx
|
|
42
|
+
## Nginx 当前项目边界
|
|
44
43
|
|
|
45
|
-
- 运维只在服务器初次接入时让共享 Nginx include `/xxzx/home/*/nginx/site.conf`;部署脚本不得编辑该全局配置。
|
|
46
44
|
- 每个项目只写自身 `nginx/site.conf` 和 `nginx/active-upstreams.conf`。
|
|
47
45
|
- upstream 名称由严格项目名确定,不接受自由文本配置。
|
|
48
|
-
-
|
|
49
|
-
-
|
|
46
|
+
- 安装候选配置后运行 `nginx -t` 和 reload;失败时恢复本项目旧文件。
|
|
47
|
+
- 不扫描、校验或修改其他项目配置、端口或 Nginx include。
|
|
50
48
|
|
|
51
49
|
## 禁止事项
|
|
52
50
|
|
|
@@ -66,7 +64,7 @@ description: 基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务
|
|
|
66
64
|
- [ ] `.env` 与 `.env.example` 的五段、变量顺序一致,部署段键相同且无重复 URL/端口配置。
|
|
67
65
|
- [ ] 外部项目端口唯一;Nginx 一次同时切换前后端,失败恢复旧流量和状态。
|
|
68
66
|
- [ ] start、replace、restart、stop、remove、status、rollback 行为与资源范围均通过测试。
|
|
69
|
-
- [ ]
|
|
67
|
+
- [ ] 不存在数据库迁移参数、迁移包内容或数据库执行路径。
|
|
70
68
|
- [ ] 连续两次构建的配置文件一致,失败不写发布记录、不暴露半成品。
|
|
71
69
|
- [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
|
|
72
70
|
- [ ] 替换或删除容器后,项目根 `logs/` 内容仍保留。
|
|
@@ -33,7 +33,6 @@ DB_PASSWORD=
|
|
|
33
33
|
DB_NAME=
|
|
34
34
|
DB_SCHEMA=
|
|
35
35
|
PROJECT_NAME=example_project
|
|
36
|
-
SERVER_PROJECTS_ROOT=/xxzx/home
|
|
37
36
|
PROJECT_HTTP_PORT=9099
|
|
38
37
|
DOCKER_BIND_IP=127.0.0.1
|
|
39
38
|
FRONTEND_BLUE_PORT=19091
|
|
@@ -42,7 +41,6 @@ BACKEND_BLUE_PORT=19093
|
|
|
42
41
|
BACKEND_GREEN_PORT=19094
|
|
43
42
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
44
43
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
45
|
-
DATABASE_MIGRATION_MODE=none
|
|
46
44
|
```
|
|
47
45
|
|
|
48
46
|
`FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT` 是应用监听的唯一来源;容器、蓝绿和项目入口的健康/版本 URL 必须由对应主机、端口与固定路径确定性构造。不得增加 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST`、`*_HEALTH_URL` 或 `*_VERSION_URL`。通配监听地址 `0.0.0.0` 仅在主动本机探测时转换为 `127.0.0.1`。
|
|
@@ -51,7 +49,7 @@ DATABASE_MIGRATION_MODE=none
|
|
|
51
49
|
|
|
52
50
|
`.env` 权限必须为 `0600` 或只读的 `0400`。部署脚本只把明确的部署键载入自身环境;数据库密码、JWT 等其他应用键保留在文件中供 Compose 注入容器,不得改变脚本的 `PATH`、Shell 选项或命令行为。
|
|
53
51
|
|
|
54
|
-
可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`
|
|
52
|
+
可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`DEPLOY_CONFIG_DIR`;未设置时只能使用与目标项目真实结构一致且已验证的标准路径。
|
|
55
53
|
|
|
56
54
|
## Docker 与日志
|
|
57
55
|
|
|
@@ -88,16 +86,13 @@ deploy/
|
|
|
88
86
|
├── manifest.sha256
|
|
89
87
|
├── docker/docker-compose.blue-green.yml
|
|
90
88
|
├── nginx/site.conf
|
|
91
|
-
└── database/ # 仅 manual 模式
|
|
92
|
-
├── migrations/
|
|
93
|
-
└── apply-migrations.sh
|
|
94
89
|
```
|
|
95
90
|
|
|
96
91
|
`release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件,禁止符号链接、硬链接秘密、绝对路径和 `..`。根 `.env` 可上传,但版本目录与镜像不得包含它。
|
|
97
92
|
|
|
98
93
|
## 服务器部署和 Nginx
|
|
99
94
|
|
|
100
|
-
|
|
95
|
+
上传目标由工程师确定;脚本通过自身物理路径确定部署根,不依赖调用者当前目录。
|
|
101
96
|
|
|
102
97
|
共享 Nginx 主配置由运维预先 include `/xxzx/home/*/nginx/site.conf`。项目脚本不得写 `/etc/nginx` 或其他项目,只维护:
|
|
103
98
|
|
|
@@ -122,10 +117,7 @@ site 监听 `PROJECT_HTTP_PORT`,并带项目名、外部端口及四个 Docker
|
|
|
122
117
|
|
|
123
118
|
## 数据库
|
|
124
119
|
|
|
125
|
-
|
|
126
|
-
- `DATABASE_MIGRATION_MODE=manual`:必须打包完整迁移目录和可执行脚本。
|
|
127
|
-
- deploy.sh 的任何服务器命令都不得调用迁移脚本、psql 或数据库客户端。
|
|
128
|
-
- build 完成和服务器 start/replace 必须明确提示 manual 操作尚需工程师单独执行,不得声称已迁移。
|
|
120
|
+
部署 Skill 不接受数据库迁移参数,也不打包、校验、提示或执行迁移文件。
|
|
129
121
|
|
|
130
122
|
## 失败边界
|
|
131
123
|
|
|
@@ -22,9 +22,9 @@ const baseKeys = new Map([
|
|
|
22
22
|
['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
|
|
23
23
|
]);
|
|
24
24
|
const deploymentKeys = [
|
|
25
|
-
'PROJECT_NAME', '
|
|
25
|
+
'PROJECT_NAME', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
|
|
26
26
|
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
27
|
-
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
27
|
+
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
|
|
@@ -73,8 +73,14 @@ function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults,
|
|
|
73
73
|
if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
|
|
74
74
|
throw new Error(`${relativeTarget} # 部署 keys must be empty or exactly: ${deploymentKeys.join(', ')}`);
|
|
75
75
|
}
|
|
76
|
-
if (requireExampleDefaults && sections.get('部署').some(({ value }) => value !== '')) {
|
|
77
|
-
throw new Error(`${relativeTarget} deployment example values must be empty`);
|
|
76
|
+
if (requireExampleDefaults && sections.get('部署').some(({ key, value }) => !['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'].includes(key) && value !== '')) {
|
|
77
|
+
throw new Error(`${relativeTarget} deployment example values must be empty except base-image defaults`);
|
|
78
|
+
}
|
|
79
|
+
if (requireExampleDefaults) {
|
|
80
|
+
for (const key of ['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
81
|
+
const entry = sections.get('部署').find((item) => item.key === key);
|
|
82
|
+
if (entry && entry.value !== exampleValues.get(key)) throw new Error(`${relativeTarget} must keep the standard default for ${key}`);
|
|
83
|
+
}
|
|
78
84
|
}
|
|
79
85
|
return {
|
|
80
86
|
extended: actualDeploymentKeys.length === deploymentKeys.length,
|
|
@@ -83,7 +89,7 @@ function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults,
|
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
function extendDeploymentSection(text) {
|
|
86
|
-
return `${text.trimEnd()}\n${deploymentKeys.map((key) => `${key}
|
|
92
|
+
return `${text.trimEnd()}\n${deploymentKeys.map((key) => `${key}=${['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'].includes(key) ? exampleValues.get(key) : ''}`).join('\n')}\n`;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
const args = process.argv.slice(2);
|
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -38,7 +38,9 @@ test('确定性生成全部标准文件并通过 check', async () => {
|
|
|
38
38
|
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
39
39
|
const example = await readFile(join(root, '.env.example'), 'utf8');
|
|
40
40
|
assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
|
|
41
|
-
assert.match(environment, /^# 部署\nPROJECT_NAME=\
|
|
41
|
+
assert.match(environment, /^# 部署\nPROJECT_NAME=\nPROJECT_HTTP_PORT=/m);
|
|
42
|
+
assert.match(environment, /^FRONTEND_BASE_IMAGE=nginx:stable$/m);
|
|
43
|
+
assert.match(environment, /^BACKEND_BASE_IMAGE=node:24-alpine3\.24$/m);
|
|
42
44
|
assert.equal(example, environment);
|
|
43
45
|
if (process.platform !== 'win32') assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
44
46
|
});
|
|
@@ -61,11 +61,8 @@ const requiredDeploySemantics = [
|
|
|
61
61
|
/docker save -o "\$version_root\/backend-image\.tar"/,
|
|
62
62
|
/load_dotenv "\$DEPLOY_ROOT\/\.env"/,
|
|
63
63
|
/endpoint_url "\$DOCKER_BIND_IP"/,
|
|
64
|
-
/server deploy root must be exactly/,
|
|
65
|
-
/assert_project_ports_isolated/,
|
|
66
64
|
/flock -x 9/,
|
|
67
65
|
/Nginx reload failed; old project configuration restored/,
|
|
68
|
-
/database migration was not executed/,
|
|
69
66
|
/docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
|
|
70
67
|
/\$1 == frontend \|\| \$1 == backend/,
|
|
71
68
|
];
|
|
@@ -95,11 +92,11 @@ for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL
|
|
|
95
92
|
if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '
|
|
95
|
+
for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
|
|
99
96
|
if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
|
|
100
97
|
}
|
|
101
|
-
if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx)
|
|
102
|
-
throw new Error('Nginx template must expose deterministic project and
|
|
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');
|
|
103
100
|
}
|
|
104
101
|
if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
|
|
105
102
|
|
|
@@ -114,23 +111,20 @@ const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|
|
|
|
114
111
|
if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
|
|
115
112
|
const requiredEnv = [
|
|
116
113
|
'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
|
|
117
|
-
'PROJECT_NAME', '
|
|
114
|
+
'PROJECT_NAME', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
|
|
118
115
|
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
119
|
-
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
116
|
+
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
120
117
|
];
|
|
121
118
|
for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
122
119
|
for (const key of env.keys()) {
|
|
123
|
-
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
|
|
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)) {
|
|
124
121
|
throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
|
|
125
122
|
}
|
|
126
123
|
}
|
|
127
124
|
if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
|
|
128
|
-
if (!/^\/.+[^/]$/.test(env.get('SERVER_PROJECTS_ROOT'))) throw new Error('Invalid SERVER_PROJECTS_ROOT');
|
|
129
125
|
for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
|
|
130
126
|
if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
|
|
131
127
|
}
|
|
132
|
-
const migrationMode = env.get('DATABASE_MIGRATION_MODE');
|
|
133
|
-
if (!['manual', 'none'].includes(migrationMode)) throw new Error('DATABASE_MIGRATION_MODE must be manual or none');
|
|
134
128
|
if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
|
|
135
129
|
const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
136
130
|
for (const key of allPortKeys) {
|
|
@@ -159,14 +153,7 @@ for (const path of requiredPaths) {
|
|
|
159
153
|
const info = await stat(join(versionRoot, path));
|
|
160
154
|
if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
|
|
161
155
|
}
|
|
162
|
-
|
|
163
|
-
const runner = await stat(join(versionRoot, 'database/apply-migrations.sh'));
|
|
164
|
-
if (!runner.isFile() || (runner.mode & 0o111) === 0) throw new Error('Manual migration runner must be executable');
|
|
165
|
-
const migrations = await readdir(join(versionRoot, 'database/migrations'));
|
|
166
|
-
if (!migrations.some((item) => /^V\d{3}__[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/.test(item))) throw new Error('Manual migration files are missing');
|
|
167
|
-
} else {
|
|
168
|
-
await assertMissing(join(versionRoot, 'database'), 'none mode must not package database files');
|
|
169
|
-
}
|
|
156
|
+
await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
|
|
170
157
|
|
|
171
158
|
const versionFiles = (await listFiles(versionRoot)).sort();
|
|
172
159
|
for (const path of versionFiles) {
|
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -32,7 +32,7 @@ async function temporary(prefix) {
|
|
|
32
32
|
return path;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function envText(
|
|
35
|
+
function envText(extra = '') {
|
|
36
36
|
return `# 前端
|
|
37
37
|
FRONTEND_URL=0.0.0.0
|
|
38
38
|
FRONTEND_PORT=5173
|
|
@@ -53,7 +53,6 @@ DB_SCHEMA=
|
|
|
53
53
|
|
|
54
54
|
# 部署
|
|
55
55
|
PROJECT_NAME=fixture
|
|
56
|
-
SERVER_PROJECTS_ROOT=${serverProjectsRoot}
|
|
57
56
|
PROJECT_HTTP_PORT=9099
|
|
58
57
|
DOCKER_BIND_IP=127.0.0.1
|
|
59
58
|
FRONTEND_BLUE_PORT=19091
|
|
@@ -62,11 +61,10 @@ BACKEND_BLUE_PORT=19093
|
|
|
62
61
|
BACKEND_GREEN_PORT=19094
|
|
63
62
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
64
63
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
65
|
-
DATABASE_MIGRATION_MODE=${mode}
|
|
66
64
|
${extra}`;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
async function createFixture({
|
|
67
|
+
async function createFixture({ agents = '# Target project\n', extraEnv = '' } = {}) {
|
|
70
68
|
const root = await temporary('wdyy-deploy-');
|
|
71
69
|
const fakeBin = join(root, 'fake-bin');
|
|
72
70
|
const serverProjectsRoot = join(root, 'servers');
|
|
@@ -87,19 +85,12 @@ async function createFixture({ mode = 'none', agents = '# Target project\n', ext
|
|
|
87
85
|
copyFile(frontendNginxTemplate, join(root, 'scripts/deployment/frontend-container.conf.template')),
|
|
88
86
|
copyFile(dockerignoreTemplate, join(root, '.dockerignore')),
|
|
89
87
|
writeFile(join(root, 'AGENTS.md'), agents),
|
|
90
|
-
writeFile(join(root, '.env'), envText(
|
|
88
|
+
writeFile(join(root, '.env'), envText(extraEnv)),
|
|
91
89
|
writeFile(commandLog, ''),
|
|
92
90
|
]);
|
|
93
91
|
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
94
92
|
await chmod(join(root, '.env'), 0o600);
|
|
95
93
|
|
|
96
|
-
if (mode === 'manual') {
|
|
97
|
-
await mkdir(join(root, 'database/migrations'), { recursive: true });
|
|
98
|
-
await writeFile(join(root, 'database/migrations/V001__baseline.sql'), 'SELECT 1;\n');
|
|
99
|
-
await writeFile(join(root, 'scripts/apply-migrations.sh'), '#!/usr/bin/env bash\nset -euo pipefail\n');
|
|
100
|
-
await chmod(join(root, 'scripts/apply-migrations.sh'), 0o755);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
94
|
await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
|
|
104
95
|
set -euo pipefail
|
|
105
96
|
printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
|
|
@@ -175,7 +166,7 @@ if [[ "\${FAIL_STATE_INSTALL:-0}" == 1 && "$target" == */state/active.env ]]; th
|
|
|
175
166
|
exec /usr/bin/install "$@"
|
|
176
167
|
`);
|
|
177
168
|
|
|
178
|
-
return { root, fakeBin, serverProjectsRoot, commandLog
|
|
169
|
+
return { root, fakeBin, serverProjectsRoot, commandLog };
|
|
179
170
|
}
|
|
180
171
|
|
|
181
172
|
async function writeExecutable(path, content) {
|
|
@@ -251,19 +242,12 @@ test('build 生成根 .env、双镜像和四服务完整包', async () => {
|
|
|
251
242
|
assert.doesNotMatch(await readFile(join(fixture.root, `deploy/${version}/docker/docker-compose.blue-green.yml`), 'utf8'), /FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|_HEALTH_URL|_VERSION_URL/);
|
|
252
243
|
});
|
|
253
244
|
|
|
254
|
-
test('
|
|
255
|
-
const
|
|
256
|
-
assert.equal(runBuild(
|
|
257
|
-
const
|
|
258
|
-
await assert.rejects(stat(join(
|
|
259
|
-
|
|
260
|
-
const manual = await createFixture({ mode: 'manual' });
|
|
261
|
-
const built = runBuild(manual);
|
|
262
|
-
assert.equal(built.status, 0, built.stderr);
|
|
263
|
-
assert.match(built.stdout, /migration was not executed/);
|
|
264
|
-
const manualVersion = await releaseVersion(manual.root);
|
|
265
|
-
assert.ok((await stat(join(manual.root, `deploy/${manualVersion}/database/apply-migrations.sh`))).mode & 0o111);
|
|
266
|
-
assert.doesNotMatch(await readFile(join(manual.root, 'deploy/deploy.sh'), 'utf8'), /\bpsql\b/);
|
|
245
|
+
test('发布包不包含数据库迁移文件或迁移参数', async () => {
|
|
246
|
+
const fixture = await createFixture();
|
|
247
|
+
assert.equal(runBuild(fixture).status, 0);
|
|
248
|
+
const version = await releaseVersion(fixture.root);
|
|
249
|
+
await assert.rejects(stat(join(fixture.root, `deploy/${version}/database`)));
|
|
250
|
+
assert.doesNotMatch(await readFile(join(fixture.root, 'deploy/deploy.sh'), 'utf8'), /apply-migrations\.sh|database\/migrations|\bpsql\b/);
|
|
267
251
|
});
|
|
268
252
|
|
|
269
253
|
test('连续构建的 deploy.sh、Compose 和 Nginx 配置字节一致', async () => {
|
|
@@ -334,6 +318,10 @@ test('build 接受带标签基础镜像并拒绝重复键和额外版本参数',
|
|
|
334
318
|
const legacyRejected = runBuild(legacy);
|
|
335
319
|
assert.notEqual(legacyRejected.status, 0);
|
|
336
320
|
assert.match(legacyRejected.stderr, /legacy duplicate environment key/);
|
|
321
|
+
const migration = await createFixture({ extraEnv: 'DATABASE_MIGRATION_MODE=none\n' });
|
|
322
|
+
const migrationRejected = runBuild(migration);
|
|
323
|
+
assert.notEqual(migrationRejected.status, 0);
|
|
324
|
+
assert.match(migrationRejected.stderr, /legacy duplicate environment key/);
|
|
337
325
|
const reverseInclude = await createFixture();
|
|
338
326
|
await writeFile(join(reverseInclude.root, '.dockerignore'), `${await readFile(join(reverseInclude.root, '.dockerignore'), 'utf8')}!.env\n`);
|
|
339
327
|
const reverseRejected = runBuild(reverseInclude);
|
|
@@ -446,68 +434,6 @@ test('remove 只删除本项目容器、网络和两个镜像仓库并保留日
|
|
|
446
434
|
await access(join(serverRoot, 'release.env'));
|
|
447
435
|
});
|
|
448
436
|
|
|
449
|
-
test('端口冲突在加载镜像或启动容器前失败', async () => {
|
|
450
|
-
const fixture = await createFixture();
|
|
451
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
452
|
-
const version = await releaseVersion(fixture.root);
|
|
453
|
-
const serverRoot = await installOnServer(fixture);
|
|
454
|
-
await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
|
|
455
|
-
await writeFile(join(fixture.serverProjectsRoot, 'other/nginx/site.conf'), '# wdyy-project: other\n# wdyy-port: 9099\nserver { listen 9099; }\n');
|
|
456
|
-
await writeFile(fixture.commandLog, '');
|
|
457
|
-
const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
|
|
458
|
-
assert.notEqual(result.status, 0);
|
|
459
|
-
assert.match(result.stderr, /already used by another project/);
|
|
460
|
-
const log = await readFile(fixture.commandLog, 'utf8');
|
|
461
|
-
assert.doesNotMatch(log, /docker load|docker compose/);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
test('展开的全局 Nginx 中存在非标准项目端口时也拒绝部署', async () => {
|
|
465
|
-
const fixture = await createFixture();
|
|
466
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
467
|
-
const version = await releaseVersion(fixture.root);
|
|
468
|
-
const serverRoot = await installOnServer(fixture);
|
|
469
|
-
await writeFile(fixture.commandLog, '');
|
|
470
|
-
const result = runServer(fixture, serverRoot, 'start', {
|
|
471
|
-
EXPECTED_VERSION: version,
|
|
472
|
-
EXTRA_NGINX_CONFIG: 'server {\n listen 9099;\n}',
|
|
473
|
-
});
|
|
474
|
-
assert.notEqual(result.status, 0);
|
|
475
|
-
assert.match(result.stderr, /expanded Nginx configuration/);
|
|
476
|
-
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
test('其他项目预留任一 Docker blue-green 宿主机端口时在启动前失败', async () => {
|
|
480
|
-
const fixture = await createFixture();
|
|
481
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
482
|
-
const version = await releaseVersion(fixture.root);
|
|
483
|
-
const serverRoot = await installOnServer(fixture);
|
|
484
|
-
await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
|
|
485
|
-
await writeFile(
|
|
486
|
-
join(fixture.serverProjectsRoot, 'other/nginx/site.conf'),
|
|
487
|
-
'# wdyy-project: other\n# wdyy-port: 9100\n# wdyy-docker-ports: 19091,29092,29093,29094\nserver { listen 9100; }\n',
|
|
488
|
-
);
|
|
489
|
-
await writeFile(fixture.commandLog, '');
|
|
490
|
-
const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
|
|
491
|
-
assert.notEqual(result.status, 0);
|
|
492
|
-
assert.match(result.stderr, /Docker host port 19091/);
|
|
493
|
-
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
test('其他运行中容器发布项目 Docker 端口时在镜像加载前失败', async () => {
|
|
497
|
-
const fixture = await createFixture();
|
|
498
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
499
|
-
const version = await releaseVersion(fixture.root);
|
|
500
|
-
const serverRoot = await installOnServer(fixture);
|
|
501
|
-
await writeFile(fixture.commandLog, '');
|
|
502
|
-
const result = runServer(fixture, serverRoot, 'start', {
|
|
503
|
-
EXPECTED_VERSION: version,
|
|
504
|
-
DOCKER_RUNNING_PORTS: 'other_project|0.0.0.0:19093->3000/tcp, [::]:19093->3000/tcp',
|
|
505
|
-
});
|
|
506
|
-
assert.notEqual(result.status, 0);
|
|
507
|
-
assert.match(result.stderr, /Docker host port 19093 is already published by another container/);
|
|
508
|
-
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
|
|
509
|
-
});
|
|
510
|
-
|
|
511
437
|
test('replace 的 Nginx reload 失败时恢复旧 upstream 和状态', async () => {
|
|
512
438
|
const fixture = await createFixture();
|
|
513
439
|
assert.equal(runBuild(fixture).status, 0);
|
|
@@ -555,14 +481,12 @@ test('状态提交失败时恢复旧流量和旧状态', async () => {
|
|
|
555
481
|
assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
|
|
556
482
|
});
|
|
557
483
|
|
|
558
|
-
test('
|
|
484
|
+
test('服务器命令拒绝额外参数且部署根由脚本位置确定', async () => {
|
|
559
485
|
const fixture = await createFixture();
|
|
560
486
|
assert.equal(runBuild(fixture).status, 0);
|
|
561
487
|
const serverRoot = await installOnServer(fixture);
|
|
562
488
|
assert.equal(runServer(fixture, serverRoot, 'start', {}, 'unexpected').status, 2);
|
|
563
|
-
const
|
|
564
|
-
await cp(join(fixture.root, 'deploy'),
|
|
565
|
-
|
|
566
|
-
assert.notEqual(wrong.status, 0);
|
|
567
|
-
assert.match(wrong.stderr, /server deploy root must be exactly/);
|
|
489
|
+
const alternateRoot = join(fixture.root, 'alternate');
|
|
490
|
+
await cp(join(fixture.root, 'deploy'), alternateRoot, { recursive: true });
|
|
491
|
+
assert.equal(runServer(fixture, alternateRoot, 'status').status, 0);
|
|
568
492
|
});
|
|
@@ -31,22 +31,22 @@ assert_port() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
reset_configuration() {
|
|
34
|
-
unset PROJECT_NAME
|
|
34
|
+
unset PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
|
|
35
35
|
unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
36
36
|
unset FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
|
|
37
|
-
unset FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
37
|
+
unset FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
38
38
|
unset FRONTEND_DOCKERFILE BACKEND_DOCKERFILE DOCKERIGNORE_FILE
|
|
39
|
-
unset
|
|
39
|
+
unset DEPLOY_CONFIG_DIR
|
|
40
40
|
unset COMPOSE_SOURCE NGINX_SOURCE
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
is_deployment_key() {
|
|
44
44
|
case "$1" in
|
|
45
45
|
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|\
|
|
46
|
-
PROJECT_NAME|
|
|
46
|
+
PROJECT_NAME|PROJECT_HTTP_PORT|DOCKER_BIND_IP|\
|
|
47
47
|
FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT|\
|
|
48
|
-
FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE
|
|
49
|
-
FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|DOCKERIGNORE_FILE
|
|
48
|
+
FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|\
|
|
49
|
+
FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|DOCKERIGNORE_FILE|\
|
|
50
50
|
DEPLOY_CONFIG_DIR|COMPOSE_SOURCE|NGINX_SOURCE)
|
|
51
51
|
return 0 ;;
|
|
52
52
|
*) return 1 ;;
|
|
@@ -55,7 +55,7 @@ is_deployment_key() {
|
|
|
55
55
|
|
|
56
56
|
is_legacy_configuration_key() {
|
|
57
57
|
case "$1" in
|
|
58
|
-
FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|*_HEALTH_URL|*_VERSION_URL)
|
|
58
|
+
FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
|
|
59
59
|
return 0 ;;
|
|
60
60
|
*) return 1 ;;
|
|
61
61
|
esac
|
|
@@ -109,17 +109,15 @@ validate_configuration() {
|
|
|
109
109
|
local name port seen=" "
|
|
110
110
|
local -a required=(
|
|
111
111
|
FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
112
|
-
PROJECT_NAME
|
|
112
|
+
PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
|
|
113
113
|
FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
|
|
114
|
-
FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
114
|
+
FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
115
115
|
)
|
|
116
116
|
for name in "${required[@]}"; do
|
|
117
117
|
[[ -n "${!name:-}" ]] || fail "$name is required in root .env"
|
|
118
118
|
done
|
|
119
119
|
[[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] ||
|
|
120
120
|
fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
|
|
121
|
-
[[ "$SERVER_PROJECTS_ROOT" = /* && "$SERVER_PROJECTS_ROOT" != */ ]] ||
|
|
122
|
-
fail "SERVER_PROJECTS_ROOT must be an absolute path without a trailing slash"
|
|
123
121
|
[[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
|
|
124
122
|
[[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
|
|
125
123
|
[[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
|
|
@@ -132,8 +130,6 @@ validate_configuration() {
|
|
|
132
130
|
[[ "$seen" != *" $port "* ]] || fail "host ports must be unique; duplicate value: $port"
|
|
133
131
|
seen+="$port "
|
|
134
132
|
done
|
|
135
|
-
[[ "$DATABASE_MIGRATION_MODE" == manual || "$DATABASE_MIGRATION_MODE" == none ]] ||
|
|
136
|
-
fail "DATABASE_MIGRATION_MODE must be manual or none"
|
|
137
133
|
}
|
|
138
134
|
|
|
139
135
|
probe_host() {
|
|
@@ -329,7 +325,6 @@ validate_dockerignore() {
|
|
|
329
325
|
|
|
330
326
|
validate_local_package() {
|
|
331
327
|
local output_root="$1" version="$2" version_root="$1/$2"
|
|
332
|
-
local expected_count
|
|
333
328
|
[[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
|
|
334
329
|
cmp -s "$SCRIPT_PATH" "$output_root/deploy.sh" || fail "generated deploy.sh differs from its source"
|
|
335
330
|
cmp -s "$SCRIPT_DIR/.env" "$output_root/.env" || fail "generated root .env differs from project .env"
|
|
@@ -343,14 +338,8 @@ validate_local_package() {
|
|
|
343
338
|
grep -Fq 'frontend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing frontend-green"
|
|
344
339
|
grep -Fq 'backend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing backend-green"
|
|
345
340
|
grep -Fq '__PROJECT_HTTP_PORT__' "$version_root/nginx/site.conf" || fail "missing project port placeholder"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
else
|
|
349
|
-
[[ ! -e "$version_root/database" ]] || fail "none database mode must not package database files"
|
|
350
|
-
fi
|
|
351
|
-
expected_count=4
|
|
352
|
-
[[ "$DATABASE_MIGRATION_MODE" == manual ]] && expected_count=6
|
|
353
|
-
[[ "$(find "$version_root" -type f ! -name manifest.sha256 | wc -l | tr -d ' ')" -ge "$expected_count" ]] || fail "release package is incomplete"
|
|
341
|
+
[[ ! -e "$version_root/database" ]] || fail "release package must not contain database migration files"
|
|
342
|
+
[[ "$(find "$version_root" -type f ! -name manifest.sha256 | wc -l | tr -d ' ')" -ge 4 ]] || fail "release package is incomplete"
|
|
354
343
|
reject_version_secrets "$version_root"
|
|
355
344
|
verify_manifest "$version_root"
|
|
356
345
|
}
|
|
@@ -368,7 +357,7 @@ build_release() {
|
|
|
368
357
|
local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md"
|
|
369
358
|
local today version raw_time build_time frontend_image backend_image
|
|
370
359
|
local frontend_dockerfile backend_dockerfile dockerignore_file deploy_config_dir compose_source nginx_source
|
|
371
|
-
local
|
|
360
|
+
local staging_parent output_root version_root agents_prepared old_deploy
|
|
372
361
|
|
|
373
362
|
acquire_build_lock
|
|
374
363
|
staging_parent=
|
|
@@ -385,18 +374,12 @@ build_release() {
|
|
|
385
374
|
deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
|
|
386
375
|
compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.blue-green.yml}")"
|
|
387
376
|
nginx_source="$(resolve_project_path "$project_root" "${NGINX_SOURCE:-${deploy_config_dir#"$project_root"/}/nginx-site.conf}")"
|
|
388
|
-
migrations_source="$(resolve_project_path "$project_root" "${MIGRATIONS_SOURCE:-database/migrations}")"
|
|
389
|
-
migration_runner_source="$(resolve_project_path "$project_root" "${MIGRATION_RUNNER_SOURCE:-scripts/apply-migrations.sh}")"
|
|
390
377
|
|
|
391
378
|
for command in node pnpm docker tar install find sort cmp awk sed grep uniq; do require_command "$command"; done
|
|
392
379
|
select_checksum_tool
|
|
393
380
|
[[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" ]] || fail "frontend and backend Dockerfiles are required"
|
|
394
381
|
[[ -f "$compose_source" && -f "$nginx_source" ]] || fail "deployment Compose and Nginx sources are required"
|
|
395
382
|
validate_dockerignore "$dockerignore_file"
|
|
396
|
-
if [[ "$DATABASE_MIGRATION_MODE" == manual ]]; then
|
|
397
|
-
[[ -d "$migrations_source" ]] || fail "manual migration directory is missing: $migrations_source"
|
|
398
|
-
[[ -x "$migration_runner_source" ]] || fail "manual migration runner is missing or not executable"
|
|
399
|
-
fi
|
|
400
383
|
|
|
401
384
|
today="$(date +%Y%m%d)"
|
|
402
385
|
version="$(release_ledger next "$agents_file" "$today")"
|
|
@@ -432,11 +415,6 @@ build_release() {
|
|
|
432
415
|
install -m 0644 "$nginx_source" "$version_root/nginx/site.conf"
|
|
433
416
|
cmp -s "$compose_source" "$version_root/docker/docker-compose.blue-green.yml" || fail "generated Compose is not deterministic"
|
|
434
417
|
cmp -s "$nginx_source" "$version_root/nginx/site.conf" || fail "generated Nginx config is not deterministic"
|
|
435
|
-
if [[ "$DATABASE_MIGRATION_MODE" == manual ]]; then
|
|
436
|
-
mkdir -p "$version_root/database"
|
|
437
|
-
cp -R "$migrations_source" "$version_root/database/migrations"
|
|
438
|
-
install -m 0755 "$migration_runner_source" "$version_root/database/apply-migrations.sh"
|
|
439
|
-
fi
|
|
440
418
|
create_manifest "$version_root"
|
|
441
419
|
validate_local_package "$output_root" "$version"
|
|
442
420
|
|
|
@@ -463,7 +441,6 @@ build_release() {
|
|
|
463
441
|
release_build_lock; BUILD_LOCK=
|
|
464
442
|
trap - EXIT
|
|
465
443
|
echo "release package created: deploy/$version"
|
|
466
|
-
[[ "$DATABASE_MIGRATION_MODE" == manual ]] && echo "database migration was not executed; run the packaged script manually after approval"
|
|
467
444
|
return 0
|
|
468
445
|
}
|
|
469
446
|
|
|
@@ -477,13 +454,9 @@ read_release_pointer() {
|
|
|
477
454
|
}
|
|
478
455
|
|
|
479
456
|
initialize_server_context() {
|
|
480
|
-
local expected_root
|
|
481
457
|
DEPLOY_ROOT="$SCRIPT_DIR"
|
|
482
458
|
load_dotenv "$DEPLOY_ROOT/.env"
|
|
483
459
|
validate_configuration
|
|
484
|
-
[[ -d "$SERVER_PROJECTS_ROOT" ]] || fail "SERVER_PROJECTS_ROOT does not exist: $SERVER_PROJECTS_ROOT"
|
|
485
|
-
expected_root="$(cd "$SERVER_PROJECTS_ROOT" >/dev/null 2>&1 && pwd -P)/$PROJECT_NAME"
|
|
486
|
-
[[ "$DEPLOY_ROOT" == "$expected_root" ]] || fail "server deploy root must be exactly $expected_root; got $DEPLOY_ROOT"
|
|
487
460
|
STATE_DIR="$DEPLOY_ROOT/state"
|
|
488
461
|
ACTIVE_STATE="$STATE_DIR/active.env"
|
|
489
462
|
PREVIOUS_STATE="$STATE_DIR/previous.env"
|
|
@@ -543,11 +516,7 @@ validate_uploaded_package() {
|
|
|
543
516
|
local package_root="$1"
|
|
544
517
|
[[ -f "$package_root/frontend-image.tar" && -f "$package_root/backend-image.tar" ]] || fail "dual image archives are missing"
|
|
545
518
|
[[ -f "$package_root/docker/docker-compose.blue-green.yml" && -f "$package_root/nginx/site.conf" ]] || fail "release configuration is incomplete"
|
|
546
|
-
|
|
547
|
-
[[ -d "$package_root/database/migrations" && -x "$package_root/database/apply-migrations.sh" ]] || fail "manual database package is incomplete"
|
|
548
|
-
else
|
|
549
|
-
[[ ! -e "$package_root/database" ]] || fail "none database mode must not contain database files"
|
|
550
|
-
fi
|
|
519
|
+
[[ ! -e "$package_root/database" ]] || fail "release package must not contain database migration files"
|
|
551
520
|
reject_version_secrets "$package_root"
|
|
552
521
|
verify_manifest "$package_root"
|
|
553
522
|
tar -tf "$package_root/frontend-image.tar" >/dev/null || fail "invalid frontend image archive"
|
|
@@ -608,12 +577,11 @@ verify_pair() {
|
|
|
608
577
|
|
|
609
578
|
render_site_candidate() {
|
|
610
579
|
local source="$1" output="$2" escaped_root
|
|
611
|
-
for placeholder in __PROJECT_NAME__ __PROJECT_HTTP_PORT__
|
|
580
|
+
for placeholder in __PROJECT_NAME__ __PROJECT_HTTP_PORT__ __DEPLOY_ROOT__ __FRONTEND_UPSTREAM_NAME__ __BACKEND_UPSTREAM_NAME__; do
|
|
612
581
|
grep -Fq "$placeholder" "$source" || fail "Nginx template is missing $placeholder"
|
|
613
582
|
done
|
|
614
583
|
escaped_root="${DEPLOY_ROOT//\\/\\\\}"; escaped_root="${escaped_root//&/\\&}"; escaped_root="${escaped_root//|/\\|}"
|
|
615
584
|
sed -e "s|__PROJECT_NAME__|$PROJECT_NAME|g" -e "s|__PROJECT_HTTP_PORT__|$PROJECT_HTTP_PORT|g" \
|
|
616
|
-
-e "s|__DOCKER_HOST_PORTS__|$FRONTEND_BLUE_PORT,$FRONTEND_GREEN_PORT,$BACKEND_BLUE_PORT,$BACKEND_GREEN_PORT|g" \
|
|
617
585
|
-e "s|__DEPLOY_ROOT__|$escaped_root|g" -e "s|__FRONTEND_UPSTREAM_NAME__|$FRONTEND_UPSTREAM_NAME|g" \
|
|
618
586
|
-e "s|__BACKEND_UPSTREAM_NAME__|$BACKEND_UPSTREAM_NAME|g" "$source" > "$output"
|
|
619
587
|
}
|
|
@@ -626,56 +594,6 @@ write_upstream_candidate() {
|
|
|
626
594
|
"$BACKEND_UPSTREAM_NAME" "$DOCKER_BIND_IP" "$backend_port" > "$output"
|
|
627
595
|
}
|
|
628
596
|
|
|
629
|
-
assert_project_ports_isolated() {
|
|
630
|
-
local site site_physical expanded total_listeners own_listener marker port own_upstream_listener
|
|
631
|
-
local docker_ports compose_project published_ports
|
|
632
|
-
shopt -s nullglob
|
|
633
|
-
for site in "$SERVER_PROJECTS_ROOT"/*/nginx/site.conf; do
|
|
634
|
-
site_physical="$(cd "$(dirname "$site")" >/dev/null 2>&1 && pwd -P)/$(basename "$site")"
|
|
635
|
-
[[ "$site_physical" == "$NGINX_SITE_FILE" ]] && continue
|
|
636
|
-
if grep -Eq "^# wdyy-port: ${PROJECT_HTTP_PORT}$|^[[:space:]]*listen[[:space:]]+${PROJECT_HTTP_PORT}[[:space:]]*;" "$site"; then
|
|
637
|
-
shopt -u nullglob
|
|
638
|
-
fail "PROJECT_HTTP_PORT is already used by another project: $site"
|
|
639
|
-
fi
|
|
640
|
-
marker="$(sed -n 's/^# wdyy-docker-ports: //p' "$site")"
|
|
641
|
-
[[ "$marker" =~ ^[0-9]+,[0-9]+,[0-9]+,[0-9]+$ ]] || {
|
|
642
|
-
shopt -u nullglob
|
|
643
|
-
fail "cannot prove Docker host port isolation for project site: $site"
|
|
644
|
-
}
|
|
645
|
-
marker=" ${marker//,/ } "
|
|
646
|
-
for port in $FRONTEND_BLUE_PORT $FRONTEND_GREEN_PORT $BACKEND_BLUE_PORT $BACKEND_GREEN_PORT; do
|
|
647
|
-
[[ "$marker" != *" $port "* ]] || {
|
|
648
|
-
shopt -u nullglob
|
|
649
|
-
fail "Docker host port $port is already reserved by another project: $site"
|
|
650
|
-
}
|
|
651
|
-
done
|
|
652
|
-
done
|
|
653
|
-
shopt -u nullglob
|
|
654
|
-
docker_ports="$(docker ps --format '{{.Label "com.docker.compose.project"}}|{{.Ports}}')" ||
|
|
655
|
-
fail "unable to inspect running Docker port bindings"
|
|
656
|
-
while IFS='|' read -r compose_project published_ports; do
|
|
657
|
-
[[ -n "$published_ports" && "$compose_project" != "$PROJECT_NAME" ]] || continue
|
|
658
|
-
for port in $FRONTEND_BLUE_PORT $FRONTEND_GREEN_PORT $BACKEND_BLUE_PORT $BACKEND_GREEN_PORT; do
|
|
659
|
-
if grep -Eq "(^|[[:space:],])([^,[:space:]]*:)?${port}->" <<< "$published_ports"; then
|
|
660
|
-
fail "Docker host port $port is already published by another container"
|
|
661
|
-
fi
|
|
662
|
-
done
|
|
663
|
-
done <<< "$docker_ports"
|
|
664
|
-
expanded="$(nginx -T 2>&1)" || fail "current global Nginx configuration is invalid"
|
|
665
|
-
total_listeners="$(grep -Ec "^[[:space:]]*listen[[:space:]]+${PROJECT_HTTP_PORT}[[:space:]]*;" <<< "$expanded" || true)"
|
|
666
|
-
if grep -Fq "# wdyy-project: $PROJECT_NAME" <<< "$expanded"; then own_listener=1; fi
|
|
667
|
-
(( total_listeners <= own_listener )) || fail "PROJECT_HTTP_PORT is already used outside this project in expanded Nginx configuration"
|
|
668
|
-
for port in $FRONTEND_BLUE_PORT $FRONTEND_GREEN_PORT $BACKEND_BLUE_PORT $BACKEND_GREEN_PORT; do
|
|
669
|
-
total_listeners="$(grep -Ec "^[[:space:]]*server[[:space:]]+[^;[:space:]]+:${port}[[:space:]]*;|proxy_pass[[:space:]]+https?://[^;[:space:]]+:${port}([/;]|$)" <<< "$expanded" || true)"
|
|
670
|
-
own_upstream_listener=0
|
|
671
|
-
if grep -Eq "upstream[[:space:]]+(${FRONTEND_UPSTREAM_NAME}|${BACKEND_UPSTREAM_NAME})" <<< "$expanded" &&
|
|
672
|
-
[[ -f "$UPSTREAM_FILE" ]] && grep -Eq ":${port}[[:space:]]*;" "$UPSTREAM_FILE"; then
|
|
673
|
-
own_upstream_listener=1
|
|
674
|
-
fi
|
|
675
|
-
(( total_listeners <= own_upstream_listener )) || fail "Docker host port $port is already used outside this project in expanded Nginx configuration"
|
|
676
|
-
done
|
|
677
|
-
}
|
|
678
|
-
|
|
679
597
|
restore_file() {
|
|
680
598
|
local backup="$1" target="$2"
|
|
681
599
|
if [[ -f "$backup" ]]; then cp -p "$backup" "$target"; else rm -f -- "$target"; fi
|
|
@@ -683,7 +601,7 @@ restore_file() {
|
|
|
683
601
|
|
|
684
602
|
commit_traffic_and_state() {
|
|
685
603
|
local site_candidate="$1" upstream_candidate="$2" active_candidate="$3" previous_candidate="${4:-}" expected_version="$5"
|
|
686
|
-
local transaction
|
|
604
|
+
local transaction public_health public_version_url public_version rollback_failed=0
|
|
687
605
|
mkdir -p "$NGINX_DIR" "$STATE_DIR"
|
|
688
606
|
transaction="$(mktemp -d "$DEPLOY_ROOT/.nginx-switch.XXXXXX")"
|
|
689
607
|
[[ -f "$NGINX_SITE_FILE" ]] && cp -p "$NGINX_SITE_FILE" "$transaction/site.old"
|
|
@@ -693,7 +611,6 @@ commit_traffic_and_state() {
|
|
|
693
611
|
|
|
694
612
|
exec 9>/tmp/wdyy-nginx-deploy.lock
|
|
695
613
|
flock -x 9
|
|
696
|
-
assert_project_ports_isolated
|
|
697
614
|
install -m 0644 "$site_candidate" "$NGINX_SITE_FILE"
|
|
698
615
|
install -m 0644 "$upstream_candidate" "$UPSTREAM_FILE"
|
|
699
616
|
if ! nginx -t; then
|
|
@@ -702,18 +619,6 @@ commit_traffic_and_state() {
|
|
|
702
619
|
rm -rf -- "$transaction"
|
|
703
620
|
fail "Nginx candidate validation failed; project configuration restored"
|
|
704
621
|
fi
|
|
705
|
-
expanded="$(nginx -T 2>&1)" || {
|
|
706
|
-
restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
|
|
707
|
-
restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
|
|
708
|
-
rm -rf -- "$transaction"
|
|
709
|
-
fail "unable to inspect expanded Nginx configuration"
|
|
710
|
-
}
|
|
711
|
-
grep -Fq "# wdyy-project: $PROJECT_NAME" <<< "$expanded" || {
|
|
712
|
-
restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
|
|
713
|
-
restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
|
|
714
|
-
rm -rf -- "$transaction"
|
|
715
|
-
fail "shared Nginx does not include this project's site.conf"
|
|
716
|
-
}
|
|
717
622
|
if ! nginx -s reload; then
|
|
718
623
|
restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
|
|
719
624
|
restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
|
|
@@ -767,7 +672,6 @@ deploy_release() {
|
|
|
767
672
|
target_color=$([[ "$ACTIVE_COLOR" == blue ]] && printf green || printf blue)
|
|
768
673
|
fi
|
|
769
674
|
mkdir -p "$STATE_DIR" "$NGINX_DIR" "$DEPLOY_ROOT/logs"
|
|
770
|
-
assert_project_ports_isolated
|
|
771
675
|
load_release_images "$RELEASE_VERSION"
|
|
772
676
|
up_pair "$target_color" "$RELEASE_VERSION"
|
|
773
677
|
verify_pair "$target_color" "$RELEASE_VERSION"
|
|
@@ -786,7 +690,6 @@ deploy_release() {
|
|
|
786
690
|
rm -rf -- "$work"
|
|
787
691
|
trap - EXIT
|
|
788
692
|
echo "active release: $RELEASE_VERSION ($target_color)"
|
|
789
|
-
[[ "$DATABASE_MIGRATION_MODE" == manual ]] && echo "database migration was not executed; execute $package_root/database/apply-migrations.sh manually after approval"
|
|
790
693
|
return 0
|
|
791
694
|
}
|
|
792
695
|
|
|
@@ -18,7 +18,6 @@ DB_SCHEMA=
|
|
|
18
18
|
|
|
19
19
|
# 部署
|
|
20
20
|
PROJECT_NAME=
|
|
21
|
-
SERVER_PROJECTS_ROOT=
|
|
22
21
|
PROJECT_HTTP_PORT=
|
|
23
22
|
DOCKER_BIND_IP=
|
|
24
23
|
FRONTEND_BLUE_PORT=
|
|
@@ -27,4 +26,3 @@ BACKEND_BLUE_PORT=
|
|
|
27
26
|
BACKEND_GREEN_PORT=
|
|
28
27
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
29
28
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
30
|
-
DATABASE_MIGRATION_MODE=
|
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ wdyy update-skill --all
|
|
|
64
64
|
|
|
65
65
|
### 使用发布脚本
|
|
66
66
|
|
|
67
|
-
推荐从项目根目录手动运行 `release_npm.sh
|
|
67
|
+
推荐从项目根目录手动运行 `release_npm.sh`。脚本会检查工作区、组织权限、lint 与打包内容,自动执行 `npm version <patch|minor|major>`、`npm publish --access public`;发布成功后等待 5 秒,再一次查询刚发布的确切版本进行 registry 核验。它不会执行登录、浏览器认证或处理认证凭据。
|
|
68
68
|
|
|
69
69
|
首次运行或登录失效时,先手工登录;脚本检测到未登录也会提示并退出:
|
|
70
70
|
|