@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,29 +1,462 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- : "${PROJECT_NAME:?PROJECT_NAME is required}"
5
- : "${DATABASE_URL:?DATABASE_URL is required}"
6
-
7
- DEPLOY_ROOT="${DEPLOY_ROOT:-/srv/$PROJECT_NAME}"
8
- COMPOSE_FILE="${COMPOSE_FILE:-$DEPLOY_ROOT/deploy/docker/docker-compose.blue-green.yml}"
9
- UPSTREAM_FILE="${UPSTREAM_FILE:-$DEPLOY_ROOT/deploy/nginx/backend-active.conf}"
10
- MIGRATION_RUNNER="${MIGRATION_RUNNER:-$DEPLOY_ROOT/scripts/apply-migrations.sh}"
11
- BACKEND_IMAGE_PREFIX="${BACKEND_IMAGE_PREFIX:-$PROJECT_NAME-backend}"
12
- BLUE_PORT="${BLUE_PORT:-3001}"
13
- GREEN_PORT="${GREEN_PORT:-3002}"
14
- STATE_DIR="$DEPLOY_ROOT/state"
15
- ACTIVE_STATE="$STATE_DIR/active.env"
16
- PREVIOUS_STATE="$STATE_DIR/previous.env"
17
- INCOMING_DIR="$DEPLOY_ROOT/incoming"
18
- RELEASES_DIR="$DEPLOY_ROOT/releases"
19
-
20
- mkdir -p "$STATE_DIR" "$RELEASES_DIR"
4
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
5
+ SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "${BASH_SOURCE[0]}")"
6
+
7
+ fail() {
8
+ echo "$*" >&2
9
+ exit 1
10
+ }
11
+
12
+ usage() {
13
+ echo "usage: ./deploy.sh build|start|stop|restart|status|rollback <version>" >&2
14
+ exit 2
15
+ }
21
16
 
22
17
  assert_version() {
23
- [[ "${1:-}" =~ ^[0-9]{8}-[0-9]{3}$ ]] || {
24
- echo "version must match YYYYMMDD-NNN" >&2
25
- exit 2
18
+ [[ "${1:-}" =~ ^[0-9]{8}-[0-9]{3}$ ]] || fail "version must match YYYYMMDD-NNN"
19
+ }
20
+
21
+ assert_port() {
22
+ local name="$1" value="$2"
23
+ [[ "$value" =~ ^[0-9]+$ ]] || fail "$name must be a numeric port"
24
+ (( value >= 1 && value <= 65535 )) || fail "$name must be between 1 and 65535"
25
+ }
26
+
27
+ require_command() {
28
+ command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
29
+ }
30
+
31
+ load_env_if_present() {
32
+ local file="$1"
33
+ if [[ -f "$file" ]]; then
34
+ set -a
35
+ # shellcheck disable=SC1090
36
+ source "$file"
37
+ set +a
38
+ fi
39
+ }
40
+
41
+ load_required_env() {
42
+ local file="$1"
43
+ [[ -r "$file" ]] || fail "required production environment file is missing: $file"
44
+ set -a
45
+ # shellcheck disable=SC1090
46
+ source "$file"
47
+ set +a
48
+ }
49
+
50
+ resolve_project_path() {
51
+ local project_root="$1" candidate="$2"
52
+ if [[ "$candidate" = /* ]]; then
53
+ printf '%s\n' "$candidate"
54
+ else
55
+ printf '%s/%s\n' "$project_root" "$candidate"
56
+ fi
57
+ }
58
+
59
+ select_checksum_tool() {
60
+ if command -v sha256sum >/dev/null 2>&1; then
61
+ CHECKSUM_TOOL=sha256sum
62
+ elif command -v shasum >/dev/null 2>&1; then
63
+ CHECKSUM_TOOL=shasum
64
+ else
65
+ fail "sha256sum or shasum is required"
66
+ fi
67
+ }
68
+
69
+ checksum_value() {
70
+ local file="$1"
71
+ if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then
72
+ sha256sum "$file" | awk '{print $1}'
73
+ else
74
+ shasum -a 256 "$file" | awk '{print $1}'
75
+ fi
76
+ }
77
+
78
+ release_ledger() {
79
+ node --input-type=module - "$@" <<'NODE'
80
+ import fs from 'node:fs';
81
+
82
+ const [mode, agentsPath, value, timestamp, outputPath] = process.argv.slice(2);
83
+ const heading = '## 发布记录';
84
+ const header = '| 版本号 | 构建时间 |';
85
+ const separator = '|---|---|';
86
+ const versionPattern = /^(\d{4})(\d{2})(\d{2})-(\d{3})$/;
87
+ const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
88
+
89
+ function fail(message) {
90
+ throw new Error(message);
91
+ }
92
+
93
+ function validCalendarDate(dateText) {
94
+ const year = Number(dateText.slice(0, 4));
95
+ const month = Number(dateText.slice(4, 6));
96
+ const day = Number(dateText.slice(6, 8));
97
+ const value = new Date(Date.UTC(year, month - 1, day));
98
+ return value.getUTCFullYear() === year
99
+ && value.getUTCMonth() === month - 1
100
+ && value.getUTCDate() === day;
101
+ }
102
+
103
+ function inspectLedger(text) {
104
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
105
+ const headings = [];
106
+ for (let index = 0; index < lines.length; index += 1) {
107
+ if (lines[index] === heading) headings.push(index);
108
+ }
109
+ if (headings.length > 1) fail('AGENTS.md contains multiple release record sections');
110
+ if (headings.length === 0) return { lines, headingIndex: -1, endIndex: lines.length, versions: [] };
111
+
112
+ const headingIndex = headings[0];
113
+ let endIndex = lines.length;
114
+ for (let index = headingIndex + 1; index < lines.length; index += 1) {
115
+ if (/^## /.test(lines[index])) {
116
+ endIndex = index;
117
+ break;
118
+ }
119
+ }
120
+
121
+ const section = lines.slice(headingIndex + 1, endIndex);
122
+ while (section[0] === '') section.shift();
123
+ while (section[section.length - 1] === '') section.pop();
124
+ if (section[0] !== header || section[1] !== separator) {
125
+ fail('AGENTS.md release record table must contain only version and build time columns');
126
+ }
127
+
128
+ const versions = [];
129
+ const unique = new Set();
130
+ for (const row of section.slice(2)) {
131
+ const match = row.match(rowPattern);
132
+ if (!match) fail(`invalid AGENTS.md release record row: ${row}`);
133
+ const versionMatch = match[1].match(versionPattern);
134
+ if (!versionMatch || !validCalendarDate(match[1].slice(0, 8))) {
135
+ fail(`invalid release version: ${match[1]}`);
136
+ }
137
+ const sequence = Number(versionMatch[4]);
138
+ if (sequence < 1 || sequence > 999) fail(`invalid release sequence: ${match[1]}`);
139
+ if (unique.has(match[1])) fail(`duplicate release version: ${match[1]}`);
140
+ unique.add(match[1]);
141
+ versions.push(match[1]);
142
+ }
143
+ return { lines, headingIndex, endIndex, versions };
144
+ }
145
+
146
+ function nextVersion(ledger, currentDate) {
147
+ if (!/^\d{8}$/.test(currentDate) || !validCalendarDate(currentDate)) {
148
+ fail(`invalid current release date: ${currentDate}`);
149
+ }
150
+ let maximum = 0;
151
+ for (const version of ledger.versions) {
152
+ if (version.startsWith(`${currentDate}-`)) {
153
+ maximum = Math.max(maximum, Number(version.slice(-3)));
154
+ }
155
+ }
156
+ if (maximum >= 999) fail(`release sequence exhausted for ${currentDate}`);
157
+ return `${currentDate}-${String(maximum + 1).padStart(3, '0')}`;
158
+ }
159
+
160
+ const original = fs.readFileSync(agentsPath, 'utf8');
161
+ const ledger = inspectLedger(original);
162
+
163
+ if (mode === 'next') {
164
+ process.stdout.write(`${nextVersion(ledger, value)}\n`);
165
+ } else if (mode === 'prepare') {
166
+ const versionMatch = value.match(versionPattern);
167
+ if (!versionMatch || !validCalendarDate(value.slice(0, 8))) fail(`invalid release version: ${value}`);
168
+ if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp)) {
169
+ fail(`invalid release build time: ${timestamp}`);
170
+ }
171
+ const expected = nextVersion(ledger, value.slice(0, 8));
172
+ if (value !== expected) fail(`release ledger changed: expected ${expected}, got ${value}`);
173
+ const row = `| ${value} | ${timestamp} |`;
174
+ let updated;
175
+ if (ledger.headingIndex === -1) {
176
+ const separatorText = original.length === 0 ? '' : original.endsWith('\n') ? '\n' : '\n\n';
177
+ updated = `${original}${separatorText}${heading}\n\n${header}\n${separator}\n${row}\n`;
178
+ } else {
179
+ const lines = [...ledger.lines];
180
+ let insertAt = ledger.endIndex;
181
+ while (insertAt > ledger.headingIndex + 1 && lines[insertAt - 1] === '') insertAt -= 1;
182
+ lines.splice(insertAt, 0, row);
183
+ updated = lines.join('\n');
26
184
  }
185
+ const modeBits = fs.statSync(agentsPath).mode;
186
+ fs.writeFileSync(outputPath, updated, { mode: modeBits });
187
+ } else {
188
+ fail(`unknown release ledger mode: ${mode}`);
189
+ }
190
+ NODE
191
+ }
192
+
193
+ create_manifest() {
194
+ local version_root="$1" file relative checksum
195
+ (
196
+ cd "$version_root"
197
+ : > manifest.sha256
198
+ find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort |
199
+ while IFS= read -r file; do
200
+ relative="${file#./}"
201
+ checksum="$(checksum_value "$file")"
202
+ printf '%s %s\n' "$checksum" "$relative"
203
+ done > manifest.sha256
204
+ )
205
+ }
206
+
207
+ verify_manifest() {
208
+ local version_root="$1"
209
+ local manifest="$version_root/manifest.sha256"
210
+ local actual_paths declared_paths result line checksum relative actual
211
+ [[ -s "$manifest" ]] || fail "missing or empty manifest.sha256"
212
+ actual_paths="$(mktemp)"
213
+ declared_paths="$(mktemp)"
214
+ result=0
215
+
216
+ if (
217
+ cd "$version_root"
218
+ find . -type f ! -name manifest.sha256 -print |
219
+ sed 's|^\./||' |
220
+ LC_ALL=C sort > "$actual_paths"
221
+ while IFS= read -r line; do
222
+ checksum="${line%% *}"
223
+ relative="${line#* }"
224
+ if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ || "$relative" == "$line" || "$relative" == /* || "$relative" == ../* || "$relative" == *"/../"* ]]; then
225
+ echo "invalid manifest entry: $line" >&2
226
+ result=1
227
+ continue
228
+ fi
229
+ printf '%s\n' "$relative" >> "$declared_paths"
230
+ [[ -f "$relative" ]] || {
231
+ echo "manifest file is missing: $relative" >&2
232
+ result=1
233
+ continue
234
+ }
235
+ actual="$(checksum_value "$relative")"
236
+ if [[ "$actual" != "$checksum" ]]; then
237
+ echo "checksum mismatch: $relative" >&2
238
+ result=1
239
+ fi
240
+ done < manifest.sha256
241
+ LC_ALL=C sort -o "$declared_paths" "$declared_paths"
242
+ if ! cmp -s "$actual_paths" "$declared_paths"; then
243
+ echo "manifest does not cover the complete release package" >&2
244
+ result=1
245
+ fi
246
+ exit "$result"
247
+ ); then
248
+ result=0
249
+ else
250
+ result=$?
251
+ fi
252
+ rm -f -- "$actual_paths" "$declared_paths"
253
+ [[ "$result" -eq 0 ]] || fail "release manifest validation failed"
254
+ }
255
+
256
+ reject_secret_files() {
257
+ local root="$1" file base
258
+ if find "$root" -type l -print -quit | grep -q .; then
259
+ fail "symbolic links must not be packaged"
260
+ fi
261
+ while IFS= read -r file; do
262
+ base="${file##*/}"
263
+ case "$base" in
264
+ .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*)
265
+ fail "production secret file must not be packaged: ${file#"$root"/}"
266
+ ;;
267
+ esac
268
+ done < <(find "$root" -type f -print)
269
+ }
270
+
271
+ validate_dockerignore() {
272
+ local file="$1" pattern
273
+ [[ -f "$file" ]] || fail "required Docker ignore file is missing: $file"
274
+ for pattern in \
275
+ '.env' \
276
+ '.env.*' \
277
+ '*.pem' \
278
+ '*.key' \
279
+ 'id_rsa' \
280
+ 'id_ed25519' \
281
+ 'credentials' \
282
+ 'credentials.*' \
283
+ 'deploy/' \
284
+ 'logs/' \
285
+ '.git/' \
286
+ 'node_modules/' \
287
+ 'venv/' \
288
+ '.pnpm-store/'; do
289
+ grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
290
+ done
291
+ }
292
+
293
+ validate_local_package() {
294
+ local output_root="$1" version="$2"
295
+ local version_root="$output_root/$version"
296
+ [[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
297
+ [[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] ||
298
+ fail "release.env does not point to the generated version"
299
+ [[ -f "$version_root/frontend.tar.gz" ]] || fail "missing frontend.tar.gz"
300
+ [[ -f "$version_root/backend-image.tar" ]] || fail "missing backend-image.tar"
301
+ [[ -d "$version_root/database/migrations" ]] || fail "missing database/migrations"
302
+ [[ -x "$version_root/scripts/apply-migrations.sh" ]] || fail "missing executable migration runner"
303
+ [[ -f "$version_root/docker/docker-compose.blue-green.yml" ]] || fail "missing blue-green compose file"
304
+ [[ -f "$version_root/nginx/site.conf" ]] || fail "missing Nginx site configuration"
305
+ grep -Fq '../../logs:/app/logs' "$version_root/docker/docker-compose.blue-green.yml" ||
306
+ fail "compose does not mount deployment-root logs"
307
+ grep -Fq '__DEPLOY_ROOT__' "$version_root/nginx/site.conf" ||
308
+ fail "Nginx configuration does not contain the deployment-root placeholder"
309
+ grep -Fq '__BACKEND_PROXY_URL__' "$version_root/nginx/site.conf" ||
310
+ fail "Nginx configuration does not contain the backend proxy URL placeholder"
311
+ reject_secret_files "$output_root"
312
+ verify_manifest "$version_root"
313
+ }
314
+
315
+ build_release() {
316
+ local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md"
317
+ local today version build_time raw_time image staging_parent output_root version_root agents_prepared
318
+ local frontend_dist backend_dockerfile migrations_source migration_runner_source
319
+ local deploy_config_dir compose_source nginx_source dockerignore_file
320
+
321
+ [[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must exist and be readable and writable"
322
+ load_env_if_present "$project_root/.env"
323
+ : "${PROJECT_NAME:?PROJECT_NAME is required in the environment or project .env}"
324
+ : "${BACKEND_CONTAINER_PORT:?BACKEND_CONTAINER_PORT is required in the environment or project .env}"
325
+ assert_port BACKEND_CONTAINER_PORT "$BACKEND_CONTAINER_PORT"
326
+
327
+ frontend_dist="$(resolve_project_path "$project_root" "${FRONTEND_DIST_DIR:-src/frontend/dist}")"
328
+ backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
329
+ dockerignore_file="$(resolve_project_path "$project_root" "${DOCKERIGNORE_FILE:-.dockerignore}")"
330
+ migrations_source="$(resolve_project_path "$project_root" "${MIGRATIONS_SOURCE:-database/migrations}")"
331
+ migration_runner_source="$(resolve_project_path "$project_root" "${MIGRATION_RUNNER_SOURCE:-scripts/apply-migrations.sh}")"
332
+ deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
333
+ compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#$project_root/}/docker-compose.blue-green.yml}")"
334
+ nginx_source="$(resolve_project_path "$project_root" "${NGINX_SOURCE:-${deploy_config_dir#$project_root/}/nginx-site.conf}")"
335
+ BACKEND_IMAGE_PREFIX="${BACKEND_IMAGE_PREFIX:-$PROJECT_NAME-backend}"
336
+
337
+ for command in node pnpm docker tar install find sort cmp awk sed grep; do
338
+ require_command "$command"
339
+ done
340
+ select_checksum_tool
341
+ [[ -f "$backend_dockerfile" ]] || fail "backend Dockerfile not found: $backend_dockerfile"
342
+ validate_dockerignore "$dockerignore_file"
343
+ [[ -d "$migrations_source" ]] || fail "migration directory not found: $migrations_source"
344
+ [[ -x "$migration_runner_source" ]] || fail "migration runner is missing or not executable: $migration_runner_source"
345
+ [[ -f "$compose_source" ]] || fail "compose source not found: $compose_source"
346
+ [[ -f "$nginx_source" ]] || fail "Nginx source not found: $nginx_source"
347
+
348
+ today="$(date +%Y%m%d)"
349
+ version="$(release_ledger next "$agents_file" "$today")"
350
+ assert_version "$version"
351
+ image="$BACKEND_IMAGE_PREFIX:$version"
352
+
353
+ echo "release gate: pnpm test"
354
+ pnpm test
355
+ echo "release gate: pnpm lint"
356
+ pnpm lint
357
+ echo "release gate: pnpm typecheck"
358
+ pnpm typecheck
359
+ echo "release gate: pnpm build"
360
+ RELEASE_VERSION="$version" VITE_RELEASE_BASE="/releases/$version/" pnpm build
361
+
362
+ [[ -f "$frontend_dist/index.html" ]] || fail "frontend build missing index.html: $frontend_dist"
363
+ grep -Fq "/releases/$version/" "$frontend_dist/index.html" ||
364
+ fail "frontend assets are not built with /releases/$version/"
365
+
366
+ echo "release gate: docker build"
367
+ docker build \
368
+ --build-arg "RELEASE_VERSION=$version" \
369
+ --build-arg "BACKEND_CONTAINER_PORT=$BACKEND_CONTAINER_PORT" \
370
+ --tag "$image" \
371
+ --file "$backend_dockerfile" \
372
+ "$project_root"
373
+ docker image inspect "$image" >/dev/null
374
+
375
+ staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
376
+ output_root="$staging_parent/deploy"
377
+ version_root="$output_root/$version"
378
+ agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
379
+ trap 'rm -rf -- "${staging_parent:-}"; rm -f -- "${agents_prepared:-}"' EXIT
380
+
381
+ mkdir -p \
382
+ "$version_root/database" \
383
+ "$version_root/scripts" \
384
+ "$version_root/docker" \
385
+ "$version_root/nginx"
386
+ install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"
387
+ printf 'RELEASE_VERSION=%s\n' "$version" > "$output_root/release.env"
388
+ tar -czf "$version_root/frontend.tar.gz" -C "$frontend_dist" .
389
+ docker save -o "$version_root/backend-image.tar" "$image"
390
+ cp -R "$migrations_source" "$version_root/database/migrations"
391
+ install -m 0755 "$migration_runner_source" "$version_root/scripts/apply-migrations.sh"
392
+ install -m 0644 "$compose_source" "$version_root/docker/docker-compose.blue-green.yml"
393
+ install -m 0644 "$nginx_source" "$version_root/nginx/site.conf"
394
+ create_manifest "$version_root"
395
+ validate_local_package "$output_root" "$version"
396
+
397
+ raw_time="$(date '+%Y-%m-%d %H:%M:%S %z')"
398
+ build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
399
+ release_ledger prepare "$agents_file" "$version" "$build_time" "$agents_prepared"
400
+
401
+ [[ "$project_root/deploy" == "$SCRIPT_DIR/deploy" ]] || fail "refusing to replace an unexpected deploy directory"
402
+ rm -rf -- "$project_root/deploy"
403
+ mv "$output_root" "$project_root/deploy"
404
+ if ! mv "$agents_prepared" "$agents_file"; then
405
+ rm -rf -- "$project_root/deploy"
406
+ fail "failed to write AGENTS.md release record"
407
+ fi
408
+
409
+ trap - EXIT
410
+ rm -rf -- "$staging_parent"
411
+ echo "release package created: deploy/$version"
412
+ }
413
+
414
+ read_release_pointer() {
415
+ local file="$DEPLOY_ROOT/release.env" line
416
+ [[ -f "$file" ]] || fail "missing release.env"
417
+ [[ "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
418
+ line="$(cat "$file")"
419
+ [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
420
+ RELEASE_VERSION="${BASH_REMATCH[1]}"
421
+ assert_version "$RELEASE_VERSION"
422
+ }
423
+
424
+ initialize_server_context() {
425
+ DEPLOY_ROOT="$SCRIPT_DIR"
426
+ load_required_env "$DEPLOY_ROOT/.env.production"
427
+ : "${PROJECT_NAME:?PROJECT_NAME is required in .env.production}"
428
+ : "${DATABASE_URL:?DATABASE_URL is required in .env.production}"
429
+ : "${BACKEND_BIND_IP:?BACKEND_BIND_IP is required in .env.production}"
430
+ : "${BACKEND_LISTEN_HOST:?BACKEND_LISTEN_HOST is required in .env.production}"
431
+ : "${BACKEND_CONTAINER_PORT:?BACKEND_CONTAINER_PORT is required in .env.production}"
432
+ : "${BACKEND_CONTAINER_HEALTH_URL:?BACKEND_CONTAINER_HEALTH_URL is required in .env.production}"
433
+ : "${BLUE_PORT:?BLUE_PORT is required in .env.production}"
434
+ : "${GREEN_PORT:?GREEN_PORT is required in .env.production}"
435
+ : "${BLUE_HEALTH_URL:?BLUE_HEALTH_URL is required in .env.production}"
436
+ : "${GREEN_HEALTH_URL:?GREEN_HEALTH_URL is required in .env.production}"
437
+ : "${BLUE_VERSION_URL:?BLUE_VERSION_URL is required in .env.production}"
438
+ : "${GREEN_VERSION_URL:?GREEN_VERSION_URL is required in .env.production}"
439
+ : "${BLUE_UPSTREAM:?BLUE_UPSTREAM is required in .env.production}"
440
+ : "${GREEN_UPSTREAM:?GREEN_UPSTREAM is required in .env.production}"
441
+ : "${BACKEND_PROXY_URL:?BACKEND_PROXY_URL is required in .env.production}"
442
+ if [[ ! "$BACKEND_PROXY_URL" =~ ^https?://([A-Za-z_][A-Za-z0-9_]*)$ ]]; then
443
+ fail "BACKEND_PROXY_URL must be an http(s) URL containing only a valid Nginx upstream name"
444
+ fi
445
+ BACKEND_UPSTREAM_NAME="${BASH_REMATCH[1]}"
446
+ BACKEND_IMAGE_PREFIX="${BACKEND_IMAGE_PREFIX:-$PROJECT_NAME-backend}"
447
+ assert_port BACKEND_CONTAINER_PORT "$BACKEND_CONTAINER_PORT"
448
+ assert_port BLUE_PORT "$BLUE_PORT"
449
+ assert_port GREEN_PORT "$GREEN_PORT"
450
+ STATE_DIR="$DEPLOY_ROOT/state"
451
+ ACTIVE_STATE="$STATE_DIR/active.env"
452
+ PREVIOUS_STATE="$STATE_DIR/previous.env"
453
+ RELEASES_DIR="$DEPLOY_ROOT/releases"
454
+ UPSTREAM_FILE="$DEPLOY_ROOT/nginx/backend-active.conf"
455
+ NGINX_SITE_FILE="$DEPLOY_ROOT/nginx/site.conf"
456
+ for command in docker curl nginx psql tar find sort cmp awk sed grep; do
457
+ require_command "$command"
458
+ done
459
+ select_checksum_tool
27
460
  }
28
461
 
29
462
  read_active() {
@@ -32,18 +465,46 @@ read_active() {
32
465
  if [[ -f "$ACTIVE_STATE" ]]; then
33
466
  # shellcheck disable=SC1090
34
467
  source "$ACTIVE_STATE"
468
+ [[ "$ACTIVE_COLOR" == blue || "$ACTIVE_COLOR" == green ]] || fail "invalid active color state"
469
+ assert_version "$ACTIVE_VERSION"
35
470
  fi
36
471
  }
37
472
 
38
- port_for() {
39
- [[ "$1" == blue ]] && printf '%s' "$BLUE_PORT" || printf '%s' "$GREEN_PORT"
473
+ upstream_for() {
474
+ [[ "$1" == blue ]] && printf '%s' "$BLUE_UPSTREAM" || printf '%s' "$GREEN_UPSTREAM"
475
+ }
476
+
477
+ compose_file_for() {
478
+ local version="$1"
479
+ assert_version "$version"
480
+ printf '%s/%s/docker/docker-compose.blue-green.yml\n' "$DEPLOY_ROOT" "$version"
481
+ }
482
+
483
+ render_nginx_site() {
484
+ local source="$1" escaped_root escaped_proxy_url
485
+ grep -Fq '__DEPLOY_ROOT__' "$source" || fail "Nginx package is missing __DEPLOY_ROOT__ placeholder"
486
+ grep -Fq '__BACKEND_PROXY_URL__' "$source" ||
487
+ fail "Nginx package is missing __BACKEND_PROXY_URL__ placeholder"
488
+ escaped_root="${DEPLOY_ROOT//\\/\\\\}"
489
+ escaped_root="${escaped_root//&/\\&}"
490
+ escaped_root="${escaped_root//|/\\|}"
491
+ escaped_proxy_url="${BACKEND_PROXY_URL//\\/\\\\}"
492
+ escaped_proxy_url="${escaped_proxy_url//&/\\&}"
493
+ escaped_proxy_url="${escaped_proxy_url//|/\\|}"
494
+ mkdir -p "$DEPLOY_ROOT/nginx"
495
+ sed \
496
+ -e "s|__DEPLOY_ROOT__|$escaped_root|g" \
497
+ -e "s|__BACKEND_PROXY_URL__|$escaped_proxy_url|g" \
498
+ "$source" > "$NGINX_SITE_FILE.new"
499
+ mv "$NGINX_SITE_FILE.new" "$NGINX_SITE_FILE"
40
500
  }
41
501
 
42
502
  switch_traffic() {
43
- local color="$1" version="$2" port backup
44
- port="$(port_for "$color")"
503
+ local color="$1" version="$2" upstream backup
504
+ upstream="$(upstream_for "$color")"
45
505
  backup="$UPSTREAM_FILE.previous"
46
- printf 'upstream backend_active {\n server 127.0.0.1:%s;\n keepalive 32;\n}\n' "$port" > "$UPSTREAM_FILE.new"
506
+ printf 'upstream %s {\n server %s;\n keepalive 32;\n}\n' \
507
+ "$BACKEND_UPSTREAM_NAME" "$upstream" > "$UPSTREAM_FILE.new"
47
508
  [[ -f "$UPSTREAM_FILE" ]] && cp "$UPSTREAM_FILE" "$backup"
48
509
  mv -f "$UPSTREAM_FILE.new" "$UPSTREAM_FILE"
49
510
  if ! nginx -t || ! nginx -s reload; then
@@ -61,82 +522,124 @@ switch_traffic() {
61
522
  }
62
523
 
63
524
  verify_backend() {
64
- local color="$1" version="$2" port health_url version_url actual
65
- port="$(port_for "$color")"
66
- health_url="http://127.0.0.1:$port/health"
67
- version_url="http://127.0.0.1:$port/version"
525
+ local color="$1" version="$2" health_url version_url actual
526
+ if [[ "$color" == blue ]]; then
527
+ health_url="$BLUE_HEALTH_URL"
528
+ version_url="$BLUE_VERSION_URL"
529
+ else
530
+ health_url="$GREEN_HEALTH_URL"
531
+ version_url="$GREEN_VERSION_URL"
532
+ fi
68
533
  HEALTH_URL="$health_url"
69
534
  VERSION_URL="$version_url"
70
- curl --fail --silent --show-error --retry 12 --retry-delay 2 "$HEALTH_URL"
535
+ curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$HEALTH_URL"
71
536
  actual="$(curl --fail --silent --show-error "$VERSION_URL")"
72
- [[ "$actual" == "$version" ]] || {
73
- echo "backend version mismatch: expected $version, got $actual" >&2
74
- exit 1
75
- }
537
+ [[ "$actual" == "$version" ]] || fail "backend version mismatch: expected $version, got $actual"
76
538
  }
77
539
 
78
- start_version() {
79
- local version="$1" source_dir release_dir target_color image
80
- assert_version "$version"
81
- source_dir="$INCOMING_DIR/$version"
82
- release_dir="$RELEASES_DIR/$version"
83
- [[ -f "$source_dir/frontend.tar.gz" ]] || { echo "missing frontend.tar.gz" >&2; exit 1; }
84
- [[ -f "$source_dir/backend-image.tar" ]] || { echo "missing backend-image.tar" >&2; exit 1; }
85
- [[ -d "$source_dir/database/migrations" ]] || { echo "missing database/migrations" >&2; exit 1; }
540
+ validate_uploaded_package() {
541
+ local package_root="$1"
542
+ select_checksum_tool
543
+ [[ -f "$package_root/frontend.tar.gz" ]] || fail "missing frontend.tar.gz"
544
+ [[ -f "$package_root/backend-image.tar" ]] || fail "missing backend-image.tar"
545
+ [[ -d "$package_root/database/migrations" ]] || fail "missing database/migrations"
546
+ [[ -x "$package_root/scripts/apply-migrations.sh" ]] || fail "missing executable migration runner"
547
+ [[ -f "$package_root/docker/docker-compose.blue-green.yml" ]] || fail "missing blue-green compose file"
548
+ [[ -f "$package_root/nginx/site.conf" ]] || fail "missing Nginx site configuration"
549
+ reject_secret_files "$package_root"
550
+ verify_manifest "$package_root"
551
+ if tar -tzf "$package_root/frontend.tar.gz" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
552
+ fail "frontend archive contains an unsafe path"
553
+ fi
554
+ }
86
555
 
87
- rm -rf "$release_dir"
556
+ start_release() {
557
+ local package_root release_dir target_color image compose_file
558
+ initialize_server_context
559
+ read_release_pointer
560
+ package_root="$DEPLOY_ROOT/$RELEASE_VERSION"
561
+ validate_uploaded_package "$package_root"
562
+ mkdir -p "$STATE_DIR" "$RELEASES_DIR" "$DEPLOY_ROOT/logs" "$DEPLOY_ROOT/nginx"
563
+ render_nginx_site "$package_root/nginx/site.conf"
564
+
565
+ release_dir="$RELEASES_DIR/$RELEASE_VERSION"
566
+ rm -rf -- "$release_dir"
88
567
  mkdir -p "$release_dir"
89
- if tar -tzf "$source_dir/frontend.tar.gz" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
90
- echo "frontend archive contains an unsafe path" >&2
91
- exit 1
92
- fi
93
- tar -xzf "$source_dir/frontend.tar.gz" -C "$release_dir"
94
- [[ -f "$release_dir/index.html" ]] || { echo "frontend release missing index.html" >&2; exit 1; }
95
- grep -q "/releases/$version/" "$release_dir/index.html" || {
96
- echo "frontend assets are not built with the versioned release URL" >&2
97
- exit 1
98
- }
99
- docker load -i "$source_dir/backend-image.tar"
100
- image="$BACKEND_IMAGE_PREFIX:$version"
568
+ tar -xzf "$package_root/frontend.tar.gz" -C "$release_dir"
569
+ [[ -f "$release_dir/index.html" ]] || fail "frontend release missing index.html"
570
+ grep -Fq "/releases/$RELEASE_VERSION/" "$release_dir/index.html" ||
571
+ fail "frontend assets are not built with the versioned release URL"
572
+
573
+ docker load -i "$package_root/backend-image.tar"
574
+ image="$BACKEND_IMAGE_PREFIX:$RELEASE_VERSION"
101
575
  docker image inspect "$image" >/dev/null
102
- MIGRATIONS_DIR="$source_dir/database/migrations" "$MIGRATION_RUNNER"
576
+ MIGRATIONS_DIR="$package_root/database/migrations" \
577
+ DATABASE_URL="$DATABASE_URL" \
578
+ "$package_root/scripts/apply-migrations.sh"
103
579
 
104
580
  read_active
105
581
  target_color=$([[ "$ACTIVE_COLOR" == blue ]] && echo green || echo blue)
582
+ compose_file="$(compose_file_for "$RELEASE_VERSION")"
106
583
  if [[ "$target_color" == blue ]]; then
107
- BACKEND_BLUE_IMAGE="$image" docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend-blue
584
+ BACKEND_BLUE_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
585
+ up -d --force-recreate backend-blue
108
586
  else
109
- BACKEND_GREEN_IMAGE="$image" docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend-green
587
+ BACKEND_GREEN_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
588
+ up -d --force-recreate backend-green
110
589
  fi
111
- verify_backend "$target_color" "$version"
590
+ verify_backend "$target_color" "$RELEASE_VERSION"
112
591
  [[ -f "$ACTIVE_STATE" ]] && cp "$ACTIVE_STATE" "$PREVIOUS_STATE"
113
- switch_traffic "$target_color" "$version"
114
- printf 'ACTIVE_COLOR=%q\nACTIVE_VERSION=%q\n' "$target_color" "$version" > "$ACTIVE_STATE"
592
+ switch_traffic "$target_color" "$RELEASE_VERSION"
593
+ printf 'ACTIVE_COLOR=%q\nACTIVE_VERSION=%q\n' "$target_color" "$RELEASE_VERSION" > "$ACTIVE_STATE"
115
594
  }
116
595
 
117
596
  restart_current() {
597
+ local target_color image compose_file
118
598
  read_active
119
- [[ -n "$ACTIVE_VERSION" ]] || { echo "no active version" >&2; exit 1; }
120
- local target_color image
599
+ [[ -n "$ACTIVE_VERSION" ]] || fail "no active version"
121
600
  target_color=$([[ "$ACTIVE_COLOR" == blue ]] && echo green || echo blue)
122
601
  image="$BACKEND_IMAGE_PREFIX:$ACTIVE_VERSION"
602
+ compose_file="$(compose_file_for "$ACTIVE_VERSION")"
603
+ [[ -f "$compose_file" ]] || fail "active version compose file is missing: $compose_file"
123
604
  if [[ "$target_color" == blue ]]; then
124
- BACKEND_BLUE_IMAGE="$image" docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend-blue
605
+ BACKEND_BLUE_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
606
+ up -d --force-recreate backend-blue
125
607
  else
126
- BACKEND_GREEN_IMAGE="$image" docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend-green
608
+ BACKEND_GREEN_IMAGE="$image" docker compose --project-name "$PROJECT_NAME" -f "$compose_file" \
609
+ up -d --force-recreate backend-green
127
610
  fi
128
611
  verify_backend "$target_color" "$ACTIVE_VERSION"
129
612
  switch_traffic "$target_color" "$ACTIVE_VERSION"
130
613
  printf 'ACTIVE_COLOR=%q\nACTIVE_VERSION=%q\n' "$target_color" "$ACTIVE_VERSION" > "$ACTIVE_STATE"
131
614
  }
132
615
 
616
+ stop_current() {
617
+ local compose_file
618
+ read_active
619
+ [[ -n "$ACTIVE_VERSION" ]] || fail "no active version"
620
+ compose_file="$(compose_file_for "$ACTIVE_VERSION")"
621
+ docker compose --project-name "$PROJECT_NAME" -f "$compose_file" down
622
+ }
623
+
624
+ status_current() {
625
+ local compose_file
626
+ read_active
627
+ printf 'color=%s\nversion=%s\n' "$ACTIVE_COLOR" "$ACTIVE_VERSION"
628
+ [[ -n "$ACTIVE_VERSION" ]] || return 0
629
+ compose_file="$(compose_file_for "$ACTIVE_VERSION")"
630
+ docker compose --project-name "$PROJECT_NAME" -f "$compose_file" ps
631
+ }
632
+
133
633
  rollback_version() {
134
634
  local version="$1"
135
635
  assert_version "$version"
136
- [[ -f "$PREVIOUS_STATE" ]] || { echo "no previous deployment" >&2; exit 1; }
636
+ [[ -f "$PREVIOUS_STATE" ]] || fail "no previous deployment"
137
637
  # shellcheck disable=SC1090
138
638
  source "$PREVIOUS_STATE"
139
- [[ "$ACTIVE_VERSION" == "$version" ]] || { echo "rollback target is not the retained previous version" >&2; exit 1; }
639
+ [[ "$ACTIVE_COLOR" == blue || "$ACTIVE_COLOR" == green ]] || fail "invalid previous color state"
640
+ assert_version "$ACTIVE_VERSION"
641
+ [[ "$ACTIVE_VERSION" == "$version" ]] || fail "rollback target is not the retained previous version"
642
+ [[ -d "$RELEASES_DIR/$ACTIVE_VERSION" ]] || fail "previous frontend release is missing"
140
643
  verify_backend "$ACTIVE_COLOR" "$ACTIVE_VERSION"
141
644
  switch_traffic "$ACTIVE_COLOR" "$ACTIVE_VERSION"
142
645
  cp "$ACTIVE_STATE" "$PREVIOUS_STATE.tmp"
@@ -145,27 +648,35 @@ rollback_version() {
145
648
  }
146
649
 
147
650
  case "${1:-}" in
651
+ build)
652
+ [[ "$#" -eq 1 ]] || usage
653
+ build_release
654
+ ;;
148
655
  start)
149
- version="$2"
150
- start_version "$version"
656
+ [[ "$#" -eq 1 ]] || usage
657
+ start_release
151
658
  ;;
152
659
  stop)
153
- docker compose -f "$COMPOSE_FILE" down
660
+ [[ "$#" -eq 1 ]] || usage
661
+ initialize_server_context
662
+ stop_current
154
663
  ;;
155
664
  restart)
665
+ [[ "$#" -eq 1 ]] || usage
666
+ initialize_server_context
156
667
  restart_current
157
668
  ;;
158
669
  status)
159
- read_active
160
- printf 'color=%s\nversion=%s\n' "$ACTIVE_COLOR" "$ACTIVE_VERSION"
161
- docker compose -f "$COMPOSE_FILE" ps
670
+ [[ "$#" -eq 1 ]] || usage
671
+ initialize_server_context
672
+ status_current
162
673
  ;;
163
674
  rollback)
164
- version="$2"
165
- rollback_version "$version"
675
+ [[ "$#" -eq 2 ]] || usage
676
+ initialize_server_context
677
+ rollback_version "$2"
166
678
  ;;
167
679
  *)
168
- echo "usage: ./deploy.sh start <version>|stop|restart|status|rollback <version>" >&2
169
- exit 2
680
+ usage
170
681
  ;;
171
682
  esac