@wdyy/skills 0.1.4 → 0.1.6
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 +4 -4
- package/.well-known/skills/wdyy-api-standard/SKILL.md +3 -2
- package/.well-known/skills/wdyy-api-standard/reference/{api-/346/240/271/346/215/256/345/260/261/350/257/212/345/217/267/350/216/267/345/217/226/346/243/200/351/252/214/346/225/260/346/215/256.md → api-demo.md} +5 -3
- package/.well-known/skills/wdyy-api-standard/reference/api-sms.md +67 -0
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +34 -21
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +129 -7
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +159 -11
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +576 -38
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +6 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +588 -77
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +10 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +15 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +5 -4
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +18 -21
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +8 -7
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +0 -19
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +71 -25
- package/.well-known/skills/wdyy-logging-standard/templates/frontend-error-report.template.ts +1 -6
- package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +26 -25
- package/package.json +1 -1
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
cp,
|
|
6
|
+
copyFile,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
readlink,
|
|
11
|
+
readdir,
|
|
12
|
+
rm,
|
|
13
|
+
stat,
|
|
14
|
+
symlink,
|
|
15
|
+
unlink,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
3
18
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
19
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
5
21
|
import { afterEach, test } from 'node:test';
|
|
6
22
|
import { spawnSync } from 'node:child_process';
|
|
7
23
|
|
|
8
|
-
const
|
|
24
|
+
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const skillRoot = resolve(scriptsRoot, '..');
|
|
26
|
+
const templatesRoot = join(skillRoot, 'templates');
|
|
27
|
+
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
28
|
+
const deployTemplate = join(templatesRoot, 'deploy.sh.template');
|
|
29
|
+
const composeTemplate = join(templatesRoot, 'docker-compose.blue-green.yml');
|
|
30
|
+
const nginxTemplate = join(templatesRoot, 'nginx-upstream.template.conf');
|
|
31
|
+
const dockerignoreTemplate = join(templatesRoot, 'dockerignore.template');
|
|
9
32
|
const temporaryDirectories = [];
|
|
10
33
|
|
|
11
34
|
afterEach(async () => {
|
|
@@ -16,57 +39,572 @@ afterEach(async () => {
|
|
|
16
39
|
);
|
|
17
40
|
});
|
|
18
41
|
|
|
19
|
-
async function
|
|
20
|
-
const directory = await mkdtemp(join(tmpdir(),
|
|
42
|
+
async function createTemporaryDirectory(prefix) {
|
|
43
|
+
const directory = await mkdtemp(join(tmpdir(), prefix));
|
|
21
44
|
temporaryDirectories.push(directory);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
return directory;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function listFiles(root, directory = root) {
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
51
|
+
const path = join(directory, entry.name);
|
|
52
|
+
if (entry.isDirectory()) result.push(...await listFiles(root, path));
|
|
53
|
+
else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeManifest(versionRoot) {
|
|
59
|
+
const paths = (await listFiles(versionRoot))
|
|
60
|
+
.filter((path) => path !== 'manifest.sha256')
|
|
61
|
+
.sort();
|
|
62
|
+
const lines = [];
|
|
63
|
+
for (const path of paths) {
|
|
64
|
+
const checksum = createHash('sha256')
|
|
65
|
+
.update(await readFile(join(versionRoot, path)))
|
|
66
|
+
.digest('hex');
|
|
67
|
+
lines.push(`${checksum} ${path}`);
|
|
68
|
+
}
|
|
69
|
+
await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function createPackage() {
|
|
73
|
+
const directory = await createTemporaryDirectory('deployment-package-');
|
|
74
|
+
const packageRoot = join(directory, 'deploy');
|
|
75
|
+
const version = '20260730-001';
|
|
76
|
+
const versionRoot = join(packageRoot, version);
|
|
77
|
+
await Promise.all([
|
|
78
|
+
mkdir(join(versionRoot, 'database', 'migrations'), { recursive: true }),
|
|
79
|
+
mkdir(join(versionRoot, 'scripts'), { recursive: true }),
|
|
80
|
+
mkdir(join(versionRoot, 'docker'), { recursive: true }),
|
|
81
|
+
mkdir(join(versionRoot, 'nginx'), { recursive: true }),
|
|
82
|
+
]);
|
|
83
|
+
await copyFile(deployTemplate, join(packageRoot, 'deploy.sh'));
|
|
84
|
+
await chmod(join(packageRoot, 'deploy.sh'), 0o755);
|
|
85
|
+
await writeFile(join(packageRoot, 'release.env'), `RELEASE_VERSION=${version}\n`);
|
|
86
|
+
await writeFile(join(versionRoot, 'frontend.tar.gz'), 'frontend archive');
|
|
87
|
+
await writeFile(join(versionRoot, 'backend-image.tar'), 'backend image');
|
|
39
88
|
await writeFile(
|
|
40
|
-
|
|
41
|
-
'
|
|
89
|
+
join(versionRoot, 'database', 'migrations', 'V001__baseline.sql'),
|
|
90
|
+
'SELECT 1;\n',
|
|
42
91
|
);
|
|
43
92
|
await writeFile(
|
|
44
|
-
|
|
45
|
-
'
|
|
93
|
+
join(versionRoot, 'scripts', 'apply-migrations.sh'),
|
|
94
|
+
'#!/usr/bin/env bash\nset -euo pipefail\n',
|
|
46
95
|
);
|
|
47
|
-
|
|
96
|
+
await chmod(join(versionRoot, 'scripts', 'apply-migrations.sh'), 0o755);
|
|
97
|
+
await copyFile(composeTemplate, join(versionRoot, 'docker', 'docker-compose.blue-green.yml'));
|
|
98
|
+
await copyFile(nginxTemplate, join(versionRoot, 'nginx', 'site.conf'));
|
|
99
|
+
await writeManifest(versionRoot);
|
|
100
|
+
return {
|
|
101
|
+
directory,
|
|
102
|
+
packageRoot,
|
|
103
|
+
version,
|
|
104
|
+
versionRoot,
|
|
105
|
+
deploy: join(packageRoot, 'deploy.sh'),
|
|
106
|
+
compose: join(versionRoot, 'docker', 'docker-compose.blue-green.yml'),
|
|
107
|
+
nginx: join(versionRoot, 'nginx', 'site.conf'),
|
|
108
|
+
};
|
|
48
109
|
}
|
|
49
110
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const result = spawnSync(
|
|
111
|
+
function runValidator(files) {
|
|
112
|
+
return spawnSync(
|
|
53
113
|
process.execPath,
|
|
54
|
-
[validator
|
|
114
|
+
[validator, files.deploy, files.nginx, files.compose, files.packageRoot],
|
|
55
115
|
{ encoding: 'utf8' },
|
|
56
116
|
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function replace(path, from, to) {
|
|
120
|
+
const content = await readFile(path, 'utf8');
|
|
121
|
+
assert.ok(content.includes(from), `fixture replacement source not found: ${from}`);
|
|
122
|
+
await writeFile(path, content.replace(from, to));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test('接受自动 build、完整单版本目录和无参数 start', async () => {
|
|
126
|
+
const files = await createPackage();
|
|
127
|
+
const result = runValidator(files);
|
|
57
128
|
|
|
58
129
|
assert.equal(result.status, 0, result.stderr);
|
|
59
|
-
assert.match(result.stdout, /valid deployment package/);
|
|
130
|
+
assert.match(result.stdout, /valid deployment package 20260730-001/);
|
|
60
131
|
});
|
|
61
132
|
|
|
62
133
|
test('缺少 rollback 命令时明确失败', async () => {
|
|
63
|
-
const files = await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
[validator.pathname, files.deploy, files.nginx, files.compose],
|
|
67
|
-
{ encoding: 'utf8' },
|
|
68
|
-
);
|
|
134
|
+
const files = await createPackage();
|
|
135
|
+
await replace(files.deploy, '\n rollback)\n', '\n legacy_command)\n');
|
|
136
|
+
const result = runValidator(files);
|
|
69
137
|
|
|
70
138
|
assert.notEqual(result.status, 0);
|
|
71
139
|
assert.match(result.stderr, /rollback/);
|
|
72
140
|
});
|
|
141
|
+
|
|
142
|
+
test('拒绝旧 start version 语法', async () => {
|
|
143
|
+
const files = await createPackage();
|
|
144
|
+
await replace(
|
|
145
|
+
files.deploy,
|
|
146
|
+
'start)\n [[ "$#" -eq 1 ]] || usage',
|
|
147
|
+
'start)\n version="$2"',
|
|
148
|
+
);
|
|
149
|
+
const result = runValidator(files);
|
|
150
|
+
|
|
151
|
+
assert.notEqual(result.status, 0);
|
|
152
|
+
assert.match(result.stderr, /start/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('拒绝固定 srv 部署根目录', async () => {
|
|
156
|
+
const files = await createPackage();
|
|
157
|
+
await replace(files.nginx, '__DEPLOY_ROOT__', '/srv/fixed-project');
|
|
158
|
+
const result = runValidator(files);
|
|
159
|
+
|
|
160
|
+
assert.notEqual(result.status, 0);
|
|
161
|
+
assert.match(result.stderr, /fixed \/srv path/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('拒绝 Nginx 模板写死代理 URL', async () => {
|
|
165
|
+
const files = await createPackage();
|
|
166
|
+
await replace(files.nginx, '__BACKEND_PROXY_URL__', 'http://backend_active');
|
|
167
|
+
const result = runValidator(files);
|
|
168
|
+
|
|
169
|
+
assert.notEqual(result.status, 0);
|
|
170
|
+
assert.match(result.stderr, /BACKEND_PROXY_URL/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('拒绝缺失迁移执行器', async () => {
|
|
174
|
+
const files = await createPackage();
|
|
175
|
+
await unlink(join(files.versionRoot, 'scripts', 'apply-migrations.sh'));
|
|
176
|
+
const result = runValidator(files);
|
|
177
|
+
|
|
178
|
+
assert.notEqual(result.status, 0);
|
|
179
|
+
assert.match(result.stderr, /apply-migrations/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('拒绝缺失校验清单', async () => {
|
|
183
|
+
const files = await createPackage();
|
|
184
|
+
await unlink(join(files.versionRoot, 'manifest.sha256'));
|
|
185
|
+
const result = runValidator(files);
|
|
186
|
+
|
|
187
|
+
assert.notEqual(result.status, 0);
|
|
188
|
+
assert.match(result.stderr, /manifest/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('拒绝生产环境文件进入版本目录', async () => {
|
|
192
|
+
const files = await createPackage();
|
|
193
|
+
await writeFile(join(files.versionRoot, '.env.production'), 'DATABASE_URL=secret\n');
|
|
194
|
+
await writeManifest(files.versionRoot);
|
|
195
|
+
const result = runValidator(files);
|
|
196
|
+
|
|
197
|
+
assert.notEqual(result.status, 0);
|
|
198
|
+
assert.match(result.stderr, /secret file/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('拒绝版本目录中的符号链接', async () => {
|
|
202
|
+
const files = await createPackage();
|
|
203
|
+
await symlink('../outside-secret', join(files.versionRoot, 'linked-secret'));
|
|
204
|
+
const result = runValidator(files);
|
|
205
|
+
|
|
206
|
+
assert.notEqual(result.status, 0);
|
|
207
|
+
assert.match(result.stderr, /symbolic links/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('拒绝文件与校验清单不一致', async () => {
|
|
211
|
+
const files = await createPackage();
|
|
212
|
+
await writeFile(join(files.versionRoot, 'backend-image.tar'), 'changed image');
|
|
213
|
+
const result = runValidator(files);
|
|
214
|
+
|
|
215
|
+
assert.notEqual(result.status, 0);
|
|
216
|
+
assert.match(result.stderr, /Checksum mismatch/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('拒绝 deploy.sh 自动上传', async () => {
|
|
220
|
+
const files = await createPackage();
|
|
221
|
+
await writeFile(files.deploy, `${await readFile(files.deploy, 'utf8')}\nscp deploy server:/tmp\n`);
|
|
222
|
+
const result = runValidator(files);
|
|
223
|
+
|
|
224
|
+
assert.notEqual(result.status, 0);
|
|
225
|
+
assert.match(result.stderr, /must not upload/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('拒绝 LOG_DIR 和 HOST_LOG_DIR 可配置日志目录', async () => {
|
|
229
|
+
const files = await createPackage();
|
|
230
|
+
await writeFile(
|
|
231
|
+
files.compose,
|
|
232
|
+
`services:
|
|
233
|
+
backend-blue:
|
|
234
|
+
environment:
|
|
235
|
+
INSTANCE_ID: blue
|
|
236
|
+
LOG_DIR: /var/log/application
|
|
237
|
+
volumes:
|
|
238
|
+
- \${HOST_LOG_DIR}/backend/blue:/app/logs
|
|
239
|
+
backend-green:
|
|
240
|
+
environment:
|
|
241
|
+
INSTANCE_ID: green
|
|
242
|
+
LOG_DIR: /var/log/application
|
|
243
|
+
volumes:
|
|
244
|
+
- \${HOST_LOG_DIR}/backend/green:/app/logs
|
|
245
|
+
`,
|
|
246
|
+
);
|
|
247
|
+
const result = runValidator(files);
|
|
248
|
+
|
|
249
|
+
assert.notEqual(result.status, 0);
|
|
250
|
+
assert.match(result.stderr, /project-root/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('拒绝 blue 和 green 使用不同日志目录', async () => {
|
|
254
|
+
const files = await createPackage();
|
|
255
|
+
await writeFile(
|
|
256
|
+
files.compose,
|
|
257
|
+
`services:
|
|
258
|
+
backend-blue:
|
|
259
|
+
environment:
|
|
260
|
+
INSTANCE_ID: blue
|
|
261
|
+
volumes:
|
|
262
|
+
- ../../logs/backend/blue:/app/logs
|
|
263
|
+
backend-green:
|
|
264
|
+
environment:
|
|
265
|
+
INSTANCE_ID: green
|
|
266
|
+
volumes:
|
|
267
|
+
- ../../logs/backend/green:/app/logs
|
|
268
|
+
`,
|
|
269
|
+
);
|
|
270
|
+
const result = runValidator(files);
|
|
271
|
+
|
|
272
|
+
assert.notEqual(result.status, 0);
|
|
273
|
+
assert.match(result.stderr, /project-root/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
async function createBuildFixture({ agentsContent = '# Target Project\n', secretMigration = false } = {}) {
|
|
277
|
+
const directory = await createTemporaryDirectory('deployment-build-');
|
|
278
|
+
const fakeBin = join(directory, 'fake-bin');
|
|
279
|
+
await Promise.all([
|
|
280
|
+
mkdir(fakeBin, { recursive: true }),
|
|
281
|
+
mkdir(join(directory, 'src', 'backend'), { recursive: true }),
|
|
282
|
+
mkdir(join(directory, 'database', 'migrations'), { recursive: true }),
|
|
283
|
+
mkdir(join(directory, 'scripts', 'deployment'), { recursive: true }),
|
|
284
|
+
]);
|
|
285
|
+
await copyFile(deployTemplate, join(directory, 'deploy.sh'));
|
|
286
|
+
await chmod(join(directory, 'deploy.sh'), 0o755);
|
|
287
|
+
await writeFile(join(directory, 'AGENTS.md'), agentsContent);
|
|
288
|
+
await writeFile(
|
|
289
|
+
join(directory, '.env'),
|
|
290
|
+
'PROJECT_NAME=fixture\nBACKEND_CONTAINER_PORT=3000\n',
|
|
291
|
+
);
|
|
292
|
+
await copyFile(dockerignoreTemplate, join(directory, '.dockerignore'));
|
|
293
|
+
await writeFile(join(directory, 'src', 'backend', 'Dockerfile'), 'FROM scratch\n');
|
|
294
|
+
await writeFile(
|
|
295
|
+
join(directory, 'database', 'migrations', 'V001__baseline.sql'),
|
|
296
|
+
'SELECT 1;\n',
|
|
297
|
+
);
|
|
298
|
+
if (secretMigration) {
|
|
299
|
+
await writeFile(
|
|
300
|
+
join(directory, 'database', 'migrations', '.env.production'),
|
|
301
|
+
'DATABASE_URL=secret\n',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
await writeFile(
|
|
305
|
+
join(directory, 'scripts', 'apply-migrations.sh'),
|
|
306
|
+
`#!/usr/bin/env bash
|
|
307
|
+
set -euo pipefail
|
|
308
|
+
if [[ -n "\${COMMAND_LOG:-}" ]]; then
|
|
309
|
+
printf 'migration %s\\n' "\${MIGRATIONS_DIR:-}" >> "$COMMAND_LOG"
|
|
310
|
+
fi
|
|
311
|
+
`,
|
|
312
|
+
);
|
|
313
|
+
await chmod(join(directory, 'scripts', 'apply-migrations.sh'), 0o755);
|
|
314
|
+
await copyFile(
|
|
315
|
+
composeTemplate,
|
|
316
|
+
join(directory, 'scripts', 'deployment', 'docker-compose.blue-green.yml'),
|
|
317
|
+
);
|
|
318
|
+
await copyFile(
|
|
319
|
+
nginxTemplate,
|
|
320
|
+
join(directory, 'scripts', 'deployment', 'nginx-site.conf'),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const fakePnpm = join(fakeBin, 'pnpm');
|
|
324
|
+
await writeFile(
|
|
325
|
+
fakePnpm,
|
|
326
|
+
`#!/usr/bin/env bash
|
|
327
|
+
set -euo pipefail
|
|
328
|
+
if [[ "\${FAIL_GATE:-}" == "\${1:-}" ]]; then
|
|
329
|
+
echo "forced pnpm failure: $1" >&2
|
|
330
|
+
exit 9
|
|
331
|
+
fi
|
|
332
|
+
if [[ "\${1:-}" == build ]]; then
|
|
333
|
+
mkdir -p src/frontend/dist
|
|
334
|
+
printf '<!doctype html><script src="%sapp.js"></script>\\n' "$VITE_RELEASE_BASE" > src/frontend/dist/index.html
|
|
335
|
+
printf 'console.log("ok")\\n' > src/frontend/dist/app.js
|
|
336
|
+
fi
|
|
337
|
+
`,
|
|
338
|
+
);
|
|
339
|
+
await chmod(fakePnpm, 0o755);
|
|
340
|
+
|
|
341
|
+
const fakeDocker = join(fakeBin, 'docker');
|
|
342
|
+
await writeFile(
|
|
343
|
+
fakeDocker,
|
|
344
|
+
`#!/usr/bin/env bash
|
|
345
|
+
set -euo pipefail
|
|
346
|
+
if [[ -n "\${COMMAND_LOG:-}" ]]; then
|
|
347
|
+
printf 'docker %s\\n' "$*" >> "$COMMAND_LOG"
|
|
348
|
+
fi
|
|
349
|
+
case "\${1:-}" in
|
|
350
|
+
build)
|
|
351
|
+
exit 0
|
|
352
|
+
;;
|
|
353
|
+
image)
|
|
354
|
+
[[ "\${2:-}" == inspect ]]
|
|
355
|
+
;;
|
|
356
|
+
save)
|
|
357
|
+
shift
|
|
358
|
+
output=
|
|
359
|
+
while [[ "$#" -gt 0 ]]; do
|
|
360
|
+
if [[ "$1" == -o ]]; then
|
|
361
|
+
output="$2"
|
|
362
|
+
shift 2
|
|
363
|
+
else
|
|
364
|
+
shift
|
|
365
|
+
fi
|
|
366
|
+
done
|
|
367
|
+
[[ -n "$output" ]]
|
|
368
|
+
printf 'fixture image\\n' > "$output"
|
|
369
|
+
;;
|
|
370
|
+
load|compose)
|
|
371
|
+
exit 0
|
|
372
|
+
;;
|
|
373
|
+
*)
|
|
374
|
+
echo "unexpected docker command: $*" >&2
|
|
375
|
+
exit 10
|
|
376
|
+
;;
|
|
377
|
+
esac
|
|
378
|
+
`,
|
|
379
|
+
);
|
|
380
|
+
await chmod(fakeDocker, 0o755);
|
|
381
|
+
|
|
382
|
+
const fakeCurl = join(fakeBin, 'curl');
|
|
383
|
+
await writeFile(
|
|
384
|
+
fakeCurl,
|
|
385
|
+
`#!/usr/bin/env bash
|
|
386
|
+
set -euo pipefail
|
|
387
|
+
url="\${!#}"
|
|
388
|
+
if [[ "$url" == */version ]]; then
|
|
389
|
+
printf '%s' "\${EXPECTED_VERSION:?}"
|
|
390
|
+
else
|
|
391
|
+
printf 'ok'
|
|
392
|
+
fi
|
|
393
|
+
`,
|
|
394
|
+
);
|
|
395
|
+
await chmod(fakeCurl, 0o755);
|
|
396
|
+
|
|
397
|
+
const fakeNginx = join(fakeBin, 'nginx');
|
|
398
|
+
await writeFile(
|
|
399
|
+
fakeNginx,
|
|
400
|
+
`#!/usr/bin/env bash
|
|
401
|
+
set -euo pipefail
|
|
402
|
+
if [[ -n "\${COMMAND_LOG:-}" ]]; then
|
|
403
|
+
printf 'nginx %s\\n' "$*" >> "$COMMAND_LOG"
|
|
404
|
+
fi
|
|
405
|
+
`,
|
|
406
|
+
);
|
|
407
|
+
await chmod(fakeNginx, 0o755);
|
|
408
|
+
|
|
409
|
+
const fakePsql = join(fakeBin, 'psql');
|
|
410
|
+
await writeFile(fakePsql, '#!/usr/bin/env bash\nset -euo pipefail\n');
|
|
411
|
+
await chmod(fakePsql, 0o755);
|
|
412
|
+
|
|
413
|
+
const fakeMv = join(fakeBin, 'mv');
|
|
414
|
+
await writeFile(
|
|
415
|
+
fakeMv,
|
|
416
|
+
`#!/usr/bin/env bash
|
|
417
|
+
set -euo pipefail
|
|
418
|
+
if [[ "\${1:-}" == -Tf ]]; then
|
|
419
|
+
shift
|
|
420
|
+
exec /bin/mv -f "$@"
|
|
421
|
+
fi
|
|
422
|
+
exec /bin/mv "$@"
|
|
423
|
+
`,
|
|
424
|
+
);
|
|
425
|
+
await chmod(fakeMv, 0o755);
|
|
426
|
+
return { directory, fakeBin, commandLog: join(directory, 'commands.log') };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function runBuild(fixture, extraEnv = {}, ...extraArguments) {
|
|
430
|
+
return spawnSync(
|
|
431
|
+
'bash',
|
|
432
|
+
['deploy.sh', 'build', ...extraArguments],
|
|
433
|
+
{
|
|
434
|
+
cwd: fixture.directory,
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
env: {
|
|
437
|
+
...process.env,
|
|
438
|
+
PATH: `${fixture.fakeBin}:${process.env.PATH}`,
|
|
439
|
+
...extraEnv,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function currentDateText() {
|
|
446
|
+
const value = new Date();
|
|
447
|
+
return [
|
|
448
|
+
String(value.getFullYear()).padStart(4, '0'),
|
|
449
|
+
String(value.getMonth() + 1).padStart(2, '0'),
|
|
450
|
+
String(value.getDate()).padStart(2, '0'),
|
|
451
|
+
].join('');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
test('build 首次生成 001、同日递增并只保留当前完整版本目录', async () => {
|
|
455
|
+
const fixture = await createBuildFixture();
|
|
456
|
+
const date = currentDateText();
|
|
457
|
+
|
|
458
|
+
const first = runBuild(fixture);
|
|
459
|
+
assert.equal(first.status, 0, first.stderr);
|
|
460
|
+
assert.equal(
|
|
461
|
+
await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8'),
|
|
462
|
+
`RELEASE_VERSION=${date}-001\n`,
|
|
463
|
+
);
|
|
464
|
+
assert.deepEqual(
|
|
465
|
+
(await readdir(join(fixture.directory, 'deploy'))).sort(),
|
|
466
|
+
[`${date}-001`, 'deploy.sh', 'release.env'].sort(),
|
|
467
|
+
);
|
|
468
|
+
assert.match(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), new RegExp(`${date}-001`));
|
|
469
|
+
assert.ok((await stat(join(
|
|
470
|
+
fixture.directory,
|
|
471
|
+
'deploy',
|
|
472
|
+
`${date}-001`,
|
|
473
|
+
'scripts',
|
|
474
|
+
'apply-migrations.sh',
|
|
475
|
+
))).mode & 0o111);
|
|
476
|
+
|
|
477
|
+
const second = runBuild(fixture);
|
|
478
|
+
assert.equal(second.status, 0, second.stderr);
|
|
479
|
+
assert.equal(
|
|
480
|
+
await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8'),
|
|
481
|
+
`RELEASE_VERSION=${date}-002\n`,
|
|
482
|
+
);
|
|
483
|
+
assert.deepEqual(
|
|
484
|
+
(await readdir(join(fixture.directory, 'deploy'))).sort(),
|
|
485
|
+
[`${date}-002`, 'deploy.sh', 'release.env'].sort(),
|
|
486
|
+
);
|
|
487
|
+
const agents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
|
|
488
|
+
assert.match(agents, new RegExp(`${date}-001`));
|
|
489
|
+
assert.match(agents, new RegExp(`${date}-002`));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('build 门禁失败时不写发布记录且不暴露 deploy 目录', async () => {
|
|
493
|
+
const fixture = await createBuildFixture();
|
|
494
|
+
const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
|
|
495
|
+
const result = runBuild(fixture, { FAIL_GATE: 'lint' });
|
|
496
|
+
|
|
497
|
+
assert.equal(result.status, 9);
|
|
498
|
+
assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
|
|
499
|
+
await assert.rejects(stat(join(fixture.directory, 'deploy')));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('build 拒绝损坏的 AGENTS 发布记录', async () => {
|
|
503
|
+
const fixture = await createBuildFixture({
|
|
504
|
+
agentsContent: '# Target\n\n## 发布记录\n\n| 版本 | 时间 |\n|---|---|\n',
|
|
505
|
+
});
|
|
506
|
+
const result = runBuild(fixture);
|
|
507
|
+
|
|
508
|
+
assert.notEqual(result.status, 0);
|
|
509
|
+
assert.match(result.stderr, /release record table/);
|
|
510
|
+
await assert.rejects(stat(join(fixture.directory, 'deploy')));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('build 拒绝把生产秘密从迁移目录带入发布包', async () => {
|
|
514
|
+
const fixture = await createBuildFixture({ secretMigration: true });
|
|
515
|
+
const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
|
|
516
|
+
const result = runBuild(fixture);
|
|
517
|
+
|
|
518
|
+
assert.notEqual(result.status, 0);
|
|
519
|
+
assert.match(result.stderr, /secret file/);
|
|
520
|
+
assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
|
|
521
|
+
await assert.rejects(stat(join(fixture.directory, 'deploy')));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('build 在 Docker ignore 不能排除秘密时明确失败', async () => {
|
|
525
|
+
const fixture = await createBuildFixture();
|
|
526
|
+
await writeFile(join(fixture.directory, '.dockerignore'), '.env\ndeploy/\n');
|
|
527
|
+
const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
|
|
528
|
+
const result = runBuild(fixture);
|
|
529
|
+
|
|
530
|
+
assert.notEqual(result.status, 0);
|
|
531
|
+
assert.match(result.stderr, /Docker ignore file is missing required pattern/);
|
|
532
|
+
assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
|
|
533
|
+
await assert.rejects(stat(join(fixture.directory, 'deploy')));
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('build 和 start 都拒绝人工版本参数', async () => {
|
|
537
|
+
const fixture = await createBuildFixture();
|
|
538
|
+
const build = runBuild(fixture, {}, '20260730-001');
|
|
539
|
+
const start = spawnSync(
|
|
540
|
+
'bash',
|
|
541
|
+
['deploy.sh', 'start', '20260730-001'],
|
|
542
|
+
{ cwd: fixture.directory, encoding: 'utf8' },
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
assert.equal(build.status, 2);
|
|
546
|
+
assert.match(build.stderr, /usage/);
|
|
547
|
+
assert.equal(start.status, 2);
|
|
548
|
+
assert.match(start.stderr, /usage/);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('上传到任意目录后 start 从 release.env 完成首个蓝色实例切换', async () => {
|
|
552
|
+
const fixture = await createBuildFixture();
|
|
553
|
+
const build = runBuild(fixture);
|
|
554
|
+
assert.equal(build.status, 0, build.stderr);
|
|
555
|
+
const releaseEnv = await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8');
|
|
556
|
+
const version = releaseEnv.trim().slice('RELEASE_VERSION='.length);
|
|
557
|
+
|
|
558
|
+
const serverRoot = join(fixture.directory, 'chosen server root');
|
|
559
|
+
await cp(join(fixture.directory, 'deploy'), serverRoot, { recursive: true });
|
|
560
|
+
await writeFile(
|
|
561
|
+
join(serverRoot, '.env.production'),
|
|
562
|
+
`PROJECT_NAME=fixture
|
|
563
|
+
DATABASE_URL=postgresql://fixture
|
|
564
|
+
BACKEND_BIND_IP=127.0.0.1
|
|
565
|
+
BACKEND_LISTEN_HOST=0.0.0.0
|
|
566
|
+
BACKEND_CONTAINER_PORT=3000
|
|
567
|
+
BACKEND_CONTAINER_HEALTH_URL=http://127.0.0.1:3000/health
|
|
568
|
+
BLUE_PORT=3001
|
|
569
|
+
GREEN_PORT=3002
|
|
570
|
+
BLUE_HEALTH_URL=http://127.0.0.1:3001/health
|
|
571
|
+
GREEN_HEALTH_URL=http://127.0.0.1:3002/health
|
|
572
|
+
BLUE_VERSION_URL=http://127.0.0.1:3001/version
|
|
573
|
+
GREEN_VERSION_URL=http://127.0.0.1:3002/version
|
|
574
|
+
BLUE_UPSTREAM=127.0.0.1:3001
|
|
575
|
+
GREEN_UPSTREAM=127.0.0.1:3002
|
|
576
|
+
BACKEND_PROXY_URL=http://backend_active
|
|
577
|
+
`,
|
|
578
|
+
);
|
|
579
|
+
const start = spawnSync(
|
|
580
|
+
'bash',
|
|
581
|
+
[join(serverRoot, 'deploy.sh'), 'start'],
|
|
582
|
+
{
|
|
583
|
+
cwd: fixture.directory,
|
|
584
|
+
encoding: 'utf8',
|
|
585
|
+
env: {
|
|
586
|
+
...process.env,
|
|
587
|
+
PATH: `${fixture.fakeBin}:${process.env.PATH}`,
|
|
588
|
+
COMMAND_LOG: fixture.commandLog,
|
|
589
|
+
EXPECTED_VERSION: version,
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
assert.equal(start.status, 0, start.stderr);
|
|
595
|
+
assert.equal(await readlink(join(serverRoot, 'current')), `releases/${version}`);
|
|
596
|
+
assert.match(await readFile(join(serverRoot, 'state', 'active.env'), 'utf8'), /ACTIVE_COLOR=blue/);
|
|
597
|
+
assert.match(await readFile(join(serverRoot, 'state', 'active.env'), 'utf8'), new RegExp(version));
|
|
598
|
+
assert.ok(await readFile(join(serverRoot, 'releases', version, 'index.html'), 'utf8'));
|
|
599
|
+
const nginxSite = await readFile(join(serverRoot, 'nginx', 'site.conf'), 'utf8');
|
|
600
|
+
assert.ok(!nginxSite.includes('__DEPLOY_ROOT__'));
|
|
601
|
+
assert.ok(!nginxSite.includes('__BACKEND_PROXY_URL__'));
|
|
602
|
+
assert.ok(nginxSite.includes(serverRoot));
|
|
603
|
+
assert.match(nginxSite, /proxy_pass http:\/\/backend_active;/);
|
|
604
|
+
const commandLog = await readFile(fixture.commandLog, 'utf8');
|
|
605
|
+
assert.match(commandLog, /migration/);
|
|
606
|
+
assert.match(commandLog, /docker load/);
|
|
607
|
+
assert.match(commandLog, /backend-blue/);
|
|
608
|
+
assert.match(commandLog, /nginx -t/);
|
|
609
|
+
assert.match(commandLog, /nginx -s reload/);
|
|
610
|
+
});
|
|
@@ -7,9 +7,14 @@ RUN pnpm build
|
|
|
7
7
|
|
|
8
8
|
FROM node:24-bookworm-slim AS runtime
|
|
9
9
|
WORKDIR /app
|
|
10
|
+
ARG RELEASE_VERSION
|
|
11
|
+
ARG BACKEND_CONTAINER_PORT
|
|
10
12
|
ENV NODE_ENV=production
|
|
13
|
+
ENV RELEASE_VERSION="${RELEASE_VERSION}"
|
|
14
|
+
ENV PORT="${BACKEND_CONTAINER_PORT}"
|
|
15
|
+
LABEL org.opencontainers.image.version="${RELEASE_VERSION}"
|
|
11
16
|
COPY --from=build /app/package.json ./
|
|
12
17
|
COPY --from=build /app/node_modules ./node_modules
|
|
13
18
|
COPY --from=build /app/dist ./dist
|
|
14
|
-
EXPOSE
|
|
19
|
+
EXPOSE ${BACKEND_CONTAINER_PORT}
|
|
15
20
|
CMD ["node", "dist/main.js"]
|