@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.
Files changed (22) hide show
  1. package/.well-known/skills/index.json +4 -4
  2. package/.well-known/skills/wdyy-api-standard/SKILL.md +3 -2
  3. 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
  4. package/.well-known/skills/wdyy-api-standard/reference/api-sms.md +67 -0
  5. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +34 -21
  6. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  7. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +129 -7
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +159 -11
  9. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +576 -38
  10. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +6 -1
  11. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +588 -77
  12. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +10 -8
  13. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +15 -0
  14. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +5 -4
  15. package/.well-known/skills/wdyy-logging-standard/SKILL.md +18 -21
  16. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
  17. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +8 -7
  18. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +0 -19
  19. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +71 -25
  20. package/.well-known/skills/wdyy-logging-standard/templates/frontend-error-report.template.ts +1 -6
  21. package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +26 -25
  22. package/package.json +1 -1
@@ -1,11 +1,34 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
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 validator = new URL('./validate-deployment-package.mjs', import.meta.url);
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 createFiles({ includeRollback = true } = {}) {
20
- const directory = await mkdtemp(join(tmpdir(), 'deployment-package-'));
42
+ async function createTemporaryDirectory(prefix) {
43
+ const directory = await mkdtemp(join(tmpdir(), prefix));
21
44
  temporaryDirectories.push(directory);
22
- const deploy = join(directory, 'deploy.sh');
23
- const nginx = join(directory, 'nginx.conf');
24
- const compose = join(directory, 'compose.yml');
25
- await writeFile(
26
- deploy,
27
- `case "$1" in
28
- start) version="$2";;
29
- stop) ;;
30
- restart) ;;
31
- status) ;;
32
- ${includeRollback ? 'rollback) version="$2";;' : ''}
33
- esac
34
- HEALTH_URL=/health
35
- VERSION_URL=/version
36
- database/migrations
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
- nginx,
41
- 'location /releases/ { root /srv/app; }\nroot /srv/app/current;\n',
89
+ join(versionRoot, 'database', 'migrations', 'V001__baseline.sql'),
90
+ 'SELECT 1;\n',
42
91
  );
43
92
  await writeFile(
44
- compose,
45
- 'backend-blue:\nbackend-green:\nINSTANCE_ID: blue\nINSTANCE_ID: green\nLOG_DIR: /logs\n',
93
+ join(versionRoot, 'scripts', 'apply-migrations.sh'),
94
+ '#!/usr/bin/env bash\nset -euo pipefail\n',
46
95
  );
47
- return { deploy, nginx, compose };
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
- test('接受版本化前端、蓝绿后端和完整命令', async () => {
51
- const files = await createFiles();
52
- const result = spawnSync(
111
+ function runValidator(files) {
112
+ return spawnSync(
53
113
  process.execPath,
54
- [validator.pathname, files.deploy, files.nginx, files.compose],
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 createFiles({ includeRollback: false });
64
- const result = spawnSync(
65
- process.execPath,
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 3000
19
+ EXPOSE ${BACKEND_CONTAINER_PORT}
15
20
  CMD ["node", "dist/main.js"]