@wdyy/skills 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +31 -40
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -55
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +9 -5
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +14 -2
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +60 -109
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +151 -277
  9. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +182 -576
  10. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
  11. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -5
  12. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
  13. package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
  14. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
  15. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
  16. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
  17. package/README.md +4 -2
  18. package/lib/wdyy-cli.js +3 -2
  19. package/package.json +1 -1
  20. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
  21. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
@@ -5,6 +5,9 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -
5
5
  SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "${BASH_SOURCE[0]}")"
6
6
  CHECKSUM_TOOL=
7
7
  BUILD_LOCK=
8
+ DEPLOY_ROOT=
9
+ RELEASE_VERSION=
10
+ OPERATION=
8
11
 
9
12
  fail() {
10
13
  echo "$*" >&2
@@ -12,7 +15,8 @@ fail() {
12
15
  }
13
16
 
14
17
  usage() {
15
- echo "usage: ./deploy.sh build|start|replace|restart|stop|remove|status|rollback <version>" >&2
18
+ echo "usage: ./deploy.sh build # local project root" >&2
19
+ echo " ./deploy.sh # server project root menu" >&2
16
20
  exit 2
17
21
  }
18
22
 
@@ -31,31 +35,29 @@ assert_port() {
31
35
  }
32
36
 
33
37
  reset_configuration() {
34
- unset PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
38
+ unset PROJECT_NAME DOCKER_BIND_IP
35
39
  unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
36
- unset FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
37
40
  unset FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
38
41
  unset FRONTEND_DOCKERFILE BACKEND_DOCKERFILE DOCKERIGNORE_FILE
39
- unset DEPLOY_CONFIG_DIR
40
- unset COMPOSE_SOURCE NGINX_SOURCE
42
+ unset DEPLOY_CONFIG_DIR COMPOSE_SOURCE
41
43
  }
42
44
 
43
45
  is_deployment_key() {
44
46
  case "$1" in
45
- FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|\
46
- PROJECT_NAME|PROJECT_HTTP_PORT|DOCKER_BIND_IP|\
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|\
50
- DEPLOY_CONFIG_DIR|COMPOSE_SOURCE|NGINX_SOURCE)
47
+ FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|PROJECT_NAME|DOCKER_BIND_IP|\
48
+ FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|\
49
+ DOCKERIGNORE_FILE|DEPLOY_CONFIG_DIR|COMPOSE_SOURCE)
51
50
  return 0 ;;
52
51
  *) return 1 ;;
53
52
  esac
54
53
  }
55
54
 
56
- is_legacy_configuration_key() {
55
+ is_removed_configuration_key() {
57
56
  case "$1" in
58
- FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
57
+ PROJECT_HTTP_PORT|FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT|\
58
+ SERVER_PROJECTS_ROOT|NGINX_SOURCE|LOG_DIR|HOST_LOG_DIR|FRONTEND_CONTAINER_PORT|\
59
+ BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|\
60
+ MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
59
61
  return 0 ;;
60
62
  *) return 1 ;;
61
63
  esac
@@ -63,16 +65,12 @@ is_legacy_configuration_key() {
63
65
 
64
66
  validate_env_permissions() {
65
67
  local file="$1" mode
66
- if stat -c '%a' "$file" >/dev/null 2>&1; then
67
- mode="$(stat -c '%a' "$file")"
68
- else
69
- mode="$(stat -f '%Lp' "$file")"
70
- fi
68
+ if stat -c '%a' "$file" >/dev/null 2>&1; then mode="$(stat -c '%a' "$file")"; else mode="$(stat -f '%Lp' "$file")"; fi
71
69
  [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
72
70
  }
73
71
 
74
72
  load_dotenv() {
75
- local file="$1" raw line key value first last
73
+ local file="$1" raw line key value first last loaded_key
76
74
  local -a loaded_keys=()
77
75
  [[ -r "$file" ]] || fail "required root environment file is missing: $file"
78
76
  validate_env_permissions "$file"
@@ -80,56 +78,31 @@ load_dotenv() {
80
78
  while IFS= read -r raw || [[ -n "$raw" ]]; do
81
79
  line="${raw%$'\r'}"
82
80
  [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
83
- [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] ||
84
- fail "invalid .env line; expected KEY=VALUE"
85
- key="${BASH_REMATCH[1]}"
86
- value="${BASH_REMATCH[2]}"
87
- [[ "$key" != LOG_DIR && "$key" != HOST_LOG_DIR ]] || fail "root .env must not configure log directories"
88
- ! is_legacy_configuration_key "$key" || fail "legacy duplicate environment key is forbidden: $key"
89
- for loaded_key in "${loaded_keys[@]:-}"; do
90
- [[ "$loaded_key" != "$key" ]] || fail "duplicate .env key: $key"
91
- done
81
+ [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "invalid .env line; expected KEY=VALUE"
82
+ key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
83
+ ! is_removed_configuration_key "$key" || fail "removed deployment configuration is forbidden: $key"
84
+ for loaded_key in "${loaded_keys[@]:-}"; do [[ "$loaded_key" != "$key" ]] || fail "duplicate .env key: $key"; done
92
85
  loaded_keys+=("$key")
93
86
  if [[ ${#value} -ge 2 ]]; then
94
- first="${value:0:1}"
95
- last="${value: -1}"
87
+ first="${value:0:1}"; last="${value: -1}"
96
88
  if [[ "$first" == "'" || "$first" == '"' ]]; then
97
89
  [[ "$last" == "$first" ]] || fail "unclosed quoted .env value: $key"
98
90
  value="${value:1:${#value}-2}"
99
91
  fi
100
92
  fi
101
- if is_deployment_key "$key"; then
102
- printf -v "$key" '%s' "$value"
103
- export "$key"
104
- fi
93
+ if is_deployment_key "$key"; then printf -v "$key" '%s' "$value"; export "$key"; fi
105
94
  done < "$file"
106
95
  }
107
96
 
108
97
  validate_configuration() {
109
- local name port seen=" "
110
- local -a required=(
111
- FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
112
- PROJECT_NAME PROJECT_HTTP_PORT DOCKER_BIND_IP
113
- FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT
114
- FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
115
- )
116
- for name in "${required[@]}"; do
117
- [[ -n "${!name:-}" ]] || fail "$name is required in root .env"
118
- done
119
- [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] ||
120
- fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
121
- [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
122
- [[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
123
- [[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
124
- for name in PROJECT_HTTP_PORT FRONTEND_PORT BACKEND_PORT \
125
- FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT; do
126
- assert_port "$name" "${!name}"
127
- done
128
- for name in PROJECT_HTTP_PORT FRONTEND_BLUE_PORT FRONTEND_GREEN_PORT BACKEND_BLUE_PORT BACKEND_GREEN_PORT; do
129
- port="${!name}"
130
- [[ "$seen" != *" $port "* ]] || fail "host ports must be unique; duplicate value: $port"
131
- seen+="$port "
132
- done
98
+ local name
99
+ local -a required=(FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE)
100
+ for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || fail "$name is required in root .env"; done
101
+ [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
102
+ for name in DOCKER_BIND_IP FRONTEND_URL BACKEND_URL; do [[ "${!name}" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "$name contains unsafe characters"; done
103
+ assert_port FRONTEND_PORT "$FRONTEND_PORT"
104
+ assert_port BACKEND_PORT "$BACKEND_PORT"
105
+ [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must be different"
133
106
  }
134
107
 
135
108
  probe_host() {
@@ -149,615 +122,248 @@ endpoint_url() {
149
122
 
150
123
  resolve_project_path() {
151
124
  local project_root="$1" candidate="$2"
152
- if [[ "$candidate" = /* ]]; then
153
- printf '%s\n' "$candidate"
154
- else
155
- printf '%s/%s\n' "$project_root" "$candidate"
156
- fi
125
+ if [[ "$candidate" = /* ]]; then printf '%s\n' "$candidate"; else printf '%s/%s\n' "$project_root" "$candidate"; fi
157
126
  }
158
127
 
159
128
  select_checksum_tool() {
160
- if command -v sha256sum >/dev/null 2>&1; then
161
- CHECKSUM_TOOL=sha256sum
162
- elif command -v shasum >/dev/null 2>&1; then
163
- CHECKSUM_TOOL=shasum
164
- else
165
- fail "sha256sum or shasum is required"
129
+ if command -v sha256sum >/dev/null 2>&1; then CHECKSUM_TOOL=sha256sum
130
+ elif command -v shasum >/dev/null 2>&1; then CHECKSUM_TOOL=shasum
131
+ else fail "sha256sum or shasum is required"
166
132
  fi
167
133
  }
168
134
 
169
135
  checksum_value() {
170
- local file="$1"
171
- if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then
172
- sha256sum "$file" | awk '{print $1}'
173
- else
174
- shasum -a 256 "$file" | awk '{print $1}'
175
- fi
136
+ if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
176
137
  }
177
138
 
178
139
  release_ledger() {
179
140
  node --input-type=module - "$@" <<'NODE'
180
141
  import fs from 'node:fs';
181
-
182
142
  const [mode, agentsPath, value, timestamp, outputPath] = process.argv.slice(2);
183
143
  const heading = '## 发布记录';
184
144
  const header = '| 版本号 | 构建时间 |';
185
145
  const separator = '|---|---|';
186
- const versionPattern = /^(\d{4})(\d{2})(\d{2})-(\d{3})$/;
187
146
  const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
188
-
189
147
  function fail(message) { throw new Error(message); }
190
- function validDate(text) {
191
- const year = Number(text.slice(0, 4));
192
- const month = Number(text.slice(4, 6));
193
- const day = Number(text.slice(6, 8));
194
- const date = new Date(Date.UTC(year, month - 1, day));
195
- return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
196
- }
148
+ function validDate(text) { const y=Number(text.slice(0,4)),m=Number(text.slice(4,6)),d=Number(text.slice(6,8)),date=new Date(Date.UTC(y,m-1,d)); return date.getUTCFullYear()===y&&date.getUTCMonth()===m-1&&date.getUTCDate()===d; }
197
149
  function inspect(text) {
198
- const lines = text.replace(/\r\n/g, '\n').split('\n');
199
- const headings = lines.flatMap((line, index) => line === heading ? [index] : []);
200
- if (headings.length > 1) fail('AGENTS.md contains multiple release record sections');
201
- if (!headings.length) return { lines, headingIndex: -1, endIndex: lines.length, versions: [] };
202
- const headingIndex = headings[0];
203
- let endIndex = lines.length;
204
- for (let index = headingIndex + 1; index < lines.length; index += 1) {
205
- if (/^## /.test(lines[index])) { endIndex = index; break; }
206
- }
207
- const section = lines.slice(headingIndex + 1, endIndex);
208
- while (section[0] === '') section.shift();
209
- while (section.at(-1) === '') section.pop();
210
- if (section[0] !== header || section[1] !== separator) fail('AGENTS.md release record table is invalid');
211
- const unique = new Set();
212
- const versions = [];
213
- for (const row of section.slice(2)) {
214
- const match = row.match(rowPattern);
215
- if (!match || !validDate(match[1].slice(0, 8))) fail(`invalid AGENTS.md release record row: ${row}`);
216
- if (unique.has(match[1])) fail(`duplicate release version: ${match[1]}`);
217
- unique.add(match[1]);
218
- versions.push(match[1]);
219
- }
220
- return { lines, headingIndex, endIndex, versions };
221
- }
222
- function nextVersion(ledger, date) {
223
- if (!/^\d{8}$/.test(date) || !validDate(date)) fail(`invalid current release date: ${date}`);
224
- const maximum = ledger.versions
225
- .filter((item) => item.startsWith(`${date}-`))
226
- .reduce((max, item) => Math.max(max, Number(item.slice(-3))), 0);
227
- if (maximum >= 999) fail(`release sequence exhausted for ${date}`);
228
- return `${date}-${String(maximum + 1).padStart(3, '0')}`;
229
- }
230
-
231
- const original = fs.readFileSync(agentsPath, 'utf8');
232
- const ledger = inspect(original);
233
- if (mode === 'next') {
234
- process.stdout.write(`${nextVersion(ledger, value)}\n`);
235
- } else if (mode === 'prepare') {
236
- if (!versionPattern.test(value) || !validDate(value.slice(0, 8))) fail(`invalid release version: ${value}`);
237
- if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp)) fail('invalid build time');
238
- const expected = nextVersion(ledger, value.slice(0, 8));
239
- if (value !== expected) fail(`release ledger changed: expected ${expected}, got ${value}`);
240
- const row = `| ${value} | ${timestamp} |`;
241
- let updated;
242
- if (ledger.headingIndex === -1) {
243
- const gap = original.length === 0 ? '' : original.endsWith('\n') ? '\n' : '\n\n';
244
- updated = `${original}${gap}${heading}\n\n${header}\n${separator}\n${row}\n`;
245
- } else {
246
- const lines = [...ledger.lines];
247
- let insertAt = ledger.endIndex;
248
- while (insertAt > ledger.headingIndex + 1 && lines[insertAt - 1] === '') insertAt -= 1;
249
- lines.splice(insertAt, 0, row);
250
- updated = lines.join('\n');
251
- }
252
- fs.writeFileSync(outputPath, updated, { mode: fs.statSync(agentsPath).mode });
253
- } else {
254
- fail(`unknown release ledger mode: ${mode}`);
255
- }
150
+ const lines=text.replace(/\r\n/g,'\n').split('\n');
151
+ const headings=lines.flatMap((line,index)=>line===heading?[index]:[]);
152
+ if(headings.length>1) fail('AGENTS.md contains multiple release record sections');
153
+ if(!headings.length) return {lines,headingIndex:-1,endIndex:lines.length,versions:[]};
154
+ const headingIndex=headings[0]; let endIndex=lines.length;
155
+ for(let index=headingIndex+1;index<lines.length;index+=1){if(/^## /.test(lines[index])){endIndex=index;break;}}
156
+ const section=lines.slice(headingIndex+1,endIndex); while(section[0]==='')section.shift(); while(section.at(-1)==='')section.pop();
157
+ if(section[0]!==header||section[1]!==separator) fail('AGENTS.md release record table is invalid');
158
+ const unique=new Set(),versions=[];
159
+ for(const row of section.slice(2)){const match=row.match(rowPattern);if(!match||!validDate(match[1].slice(0,8)))fail(`invalid AGENTS.md release record row: ${row}`);if(unique.has(match[1]))fail(`duplicate release version: ${match[1]}`);unique.add(match[1]);versions.push(match[1]);}
160
+ return {lines,headingIndex,endIndex,versions};
161
+ }
162
+ function nextVersion(ledger,date){if(!/^\d{8}$/.test(date)||!validDate(date))fail(`invalid current release date: ${date}`);const max=ledger.versions.filter(item=>item.startsWith(`${date}-`)).reduce((result,item)=>Math.max(result,Number(item.slice(-3))),0);if(max>=999)fail(`release sequence exhausted for ${date}`);return `${date}-${String(max+1).padStart(3,'0')}`;}
163
+ const original=fs.readFileSync(agentsPath,'utf8'); const ledger=inspect(original);
164
+ if(mode==='next') process.stdout.write(`${nextVersion(ledger,value)}\n`);
165
+ else if(mode==='prepare'){
166
+ if(!/^\d{8}-\d{3}$/.test(value)||!validDate(value.slice(0,8)))fail(`invalid release version: ${value}`);
167
+ if(!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp))fail('invalid build time');
168
+ const expected=nextVersion(ledger,value.slice(0,8));if(value!==expected)fail(`release ledger changed: expected ${expected}, got ${value}`);
169
+ const row=`| ${value} | ${timestamp} |`;let updated;
170
+ if(ledger.headingIndex===-1){const gap=original.length===0?'':original.endsWith('\n')?'\n':'\n\n';updated=`${original}${gap}${heading}\n\n${header}\n${separator}\n${row}\n`;}
171
+ else{const lines=[...ledger.lines];let insertAt=ledger.endIndex;while(insertAt>ledger.headingIndex+1&&lines[insertAt-1]==='')insertAt-=1;lines.splice(insertAt,0,row);updated=lines.join('\n');}
172
+ fs.writeFileSync(outputPath,updated,{mode:fs.statSync(agentsPath).mode});
173
+ } else fail(`unknown release ledger mode: ${mode}`);
256
174
  NODE
257
175
  }
258
176
 
259
177
  create_manifest() {
260
178
  local version_root="$1" file relative checksum
261
- (
262
- cd "$version_root"
263
- find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort |
264
- while IFS= read -r file; do
265
- relative="${file#./}"
266
- checksum="$(checksum_value "$file")"
267
- printf '%s %s\n' "$checksum" "$relative"
268
- done > manifest.sha256
269
- )
179
+ (cd "$version_root"; find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort | while IFS= read -r file; do relative="${file#./}"; checksum="$(checksum_value "$file")"; printf '%s %s\n' "$checksum" "$relative"; done > manifest.sha256)
270
180
  }
271
181
 
272
182
  verify_manifest() {
273
- local version_root="$1" manifest="$1/manifest.sha256"
274
- local actual_paths declared_paths result=0 line checksum relative actual
275
- [[ -s "$manifest" ]] || fail "missing or empty manifest.sha256"
276
- actual_paths="$(mktemp)"
277
- declared_paths="$(mktemp)"
278
- if (
279
- cd "$version_root"
280
- find . -type f ! -name manifest.sha256 -print | sed 's|^\./||' | LC_ALL=C sort > "$actual_paths"
281
- while IFS= read -r line; do
282
- checksum="${line%% *}"
283
- relative="${line#* }"
284
- [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != ../* && "$relative" != *"/../"* ]] || exit 11
285
- printf '%s\n' "$relative" >> "$declared_paths"
286
- [[ -f "$relative" && ! -L "$relative" ]] || exit 12
287
- actual="$(checksum_value "$relative")"
288
- [[ "$actual" == "$checksum" ]] || exit 13
289
- done < manifest.sha256
290
- LC_ALL=C sort -o "$declared_paths" "$declared_paths"
291
- [[ "$(uniq -d "$declared_paths" | wc -l | tr -d ' ')" == 0 ]] || exit 14
292
- cmp -s "$actual_paths" "$declared_paths"
293
- ); then
294
- result=0
295
- else
296
- result=$?
297
- fi
298
- rm -f -- "$actual_paths" "$declared_paths"
299
- [[ "$result" -eq 0 ]] || fail "release manifest validation failed"
183
+ local root="$1" line checksum relative actual
184
+ [[ -s "$root/manifest.sha256" ]] || fail "missing or empty manifest.sha256"
185
+ while IFS= read -r line; do
186
+ checksum="${line%% *}"; relative="${line#* }"
187
+ [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != ../* && "$relative" != *'/../'* ]] || fail "invalid manifest entry"
188
+ [[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
189
+ actual="$(checksum_value "$root/$relative")"; [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
190
+ done < "$root/manifest.sha256"
191
+ local actual_paths declared_paths
192
+ actual_paths="$(cd "$root"; find . -type f ! -name manifest.sha256 -print | sed 's|^./||' | LC_ALL=C sort)"
193
+ declared_paths="$(sed -n 's/^[0-9a-f]\{64\} //p' "$root/manifest.sha256" | LC_ALL=C sort)"
194
+ [[ "$actual_paths" == "$declared_paths" ]] || fail "manifest coverage is incomplete"
300
195
  }
301
196
 
302
197
  reject_version_secrets() {
303
198
  local root="$1" file base
304
- if find "$root" -type l -print -quit | grep -q .; then fail "symbolic links must not be packaged"; fi
305
- while IFS= read -r file; do
306
- base="${file##*/}"
307
- case "$base" in
308
- .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*)
309
- fail "secret file must not enter the version directory: ${file#"$root"/}" ;;
310
- esac
311
- done < <(find "$root" -type f -print)
199
+ ! find "$root" -type l -print -quit | grep -q . || fail "symbolic links must not be packaged"
200
+ while IFS= read -r file; do base="${file##*/}"; case "$base" in .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*) fail "secret file must not enter version directory: ${file#"$root"/}" ;; esac; done < <(find "$root" -type f -print)
312
201
  }
313
202
 
314
203
  validate_dockerignore() {
315
204
  local file="$1" pattern line
316
205
  [[ -f "$file" ]] || fail "required Docker ignore file is missing: $file"
317
- for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' \
318
- 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*'; do
319
- grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
320
- done
321
- while IFS= read -r line || [[ -n "$line" ]]; do
322
- [[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"
323
- done < "$file"
206
+ for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*'; do grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"; done
207
+ while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"; done < "$file"
324
208
  }
325
209
 
326
210
  validate_local_package() {
327
- local output_root="$1" version="$2" version_root="$1/$2"
211
+ local output_root="$1" version="$2" version_root="$1/$2" compose="$1/$2/docker/docker-compose.yml"
328
212
  [[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
329
213
  cmp -s "$SCRIPT_PATH" "$output_root/deploy.sh" || fail "generated deploy.sh differs from its source"
330
214
  cmp -s "$SCRIPT_DIR/.env" "$output_root/.env" || fail "generated root .env differs from project .env"
331
215
  [[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] || fail "invalid release.env"
332
- [[ -f "$version_root/frontend-image.tar" && -f "$version_root/backend-image.tar" ]] || fail "dual image archives are required"
333
- [[ -f "$version_root/docker/docker-compose.blue-green.yml" ]] || fail "missing compose file"
334
- [[ -f "$version_root/nginx/site.conf" ]] || fail "missing Nginx site template"
335
- [[ "$(grep -Fc '../../logs:/app/logs' "$version_root/docker/docker-compose.blue-green.yml")" == 4 ]] || fail "all four services must mount root logs"
336
- grep -Fq 'frontend-blue:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing frontend-blue"
337
- grep -Fq 'backend-blue:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing backend-blue"
338
- grep -Fq 'frontend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing frontend-green"
339
- grep -Fq 'backend-green:' "$version_root/docker/docker-compose.blue-green.yml" || fail "missing backend-green"
340
- grep -Fq '__PROJECT_HTTP_PORT__' "$version_root/nginx/site.conf" || fail "missing project port placeholder"
216
+ [[ -f "$version_root/frontend-image.tar" && -f "$version_root/backend-image.tar" && -f "$compose" ]] || fail "dual image package is incomplete"
217
+ [[ "$(grep -Ec '^ (frontend|backend):$' "$compose")" == 2 ]] || fail "Compose must define frontend and backend only"
218
+ [[ "$(grep -Fc '../../logs:/app/logs' "$compose")" == 2 ]] || fail "both services must mount root logs"
219
+ ! grep -Eqi 'blue|green|nginx-upstream|PROJECT_HTTP_PORT' "$compose" || fail "blue-green or host Nginx configuration is forbidden"
341
220
  [[ ! -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"
343
- reject_version_secrets "$version_root"
344
- verify_manifest "$version_root"
345
- }
346
-
347
- acquire_build_lock() {
348
- BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
349
- mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or stale lock exists: $BUILD_LOCK"
221
+ reject_version_secrets "$version_root"; verify_manifest "$version_root"
350
222
  }
351
223
 
352
- release_build_lock() {
353
- [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]] && rmdir "$BUILD_LOCK"
354
- }
224
+ acquire_build_lock() { BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"; mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or stale lock exists: $BUILD_LOCK"; }
225
+ release_build_lock() { [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]] && rmdir "$BUILD_LOCK"; }
355
226
 
356
227
  build_release() {
357
- local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md"
358
- local today version raw_time build_time frontend_image backend_image
359
- local frontend_dockerfile backend_dockerfile dockerignore_file deploy_config_dir compose_source nginx_source
360
- local staging_parent output_root version_root agents_prepared old_deploy
361
-
362
- acquire_build_lock
363
- staging_parent=
364
- agents_prepared=
365
- old_deploy=
228
+ local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md" today version raw_time build_time frontend_image backend_image
229
+ local frontend_dockerfile backend_dockerfile dockerignore_file deploy_config_dir compose_source staging_parent output_root version_root agents_prepared old_deploy
230
+ acquire_build_lock; staging_parent=; agents_prepared=; old_deploy=
366
231
  trap 'rm -rf -- "${staging_parent:-}" "${old_deploy:-}"; rm -f -- "${agents_prepared:-}"; release_build_lock' EXIT
367
232
  [[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must be readable and writable"
368
- load_dotenv "$project_root/.env"
369
- validate_configuration
370
-
233
+ load_dotenv "$project_root/.env"; validate_configuration
371
234
  frontend_dockerfile="$(resolve_project_path "$project_root" "${FRONTEND_DOCKERFILE:-src/frontend/Dockerfile}")"
372
235
  backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
373
236
  dockerignore_file="$(resolve_project_path "$project_root" "${DOCKERIGNORE_FILE:-.dockerignore}")"
374
237
  deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
375
- compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.blue-green.yml}")"
376
- nginx_source="$(resolve_project_path "$project_root" "${NGINX_SOURCE:-${deploy_config_dir#"$project_root"/}/nginx-site.conf}")"
377
-
378
- for command in node pnpm docker tar install find sort cmp awk sed grep uniq; do require_command "$command"; done
238
+ compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.yml}")"
239
+ for command in node pnpm docker install find sort cmp awk sed grep; do require_command "$command"; done
379
240
  select_checksum_tool
380
- [[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" ]] || fail "frontend and backend Dockerfiles are required"
381
- [[ -f "$compose_source" && -f "$nginx_source" ]] || fail "deployment Compose and Nginx sources are required"
241
+ [[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" && -f "$compose_source" ]] || fail "Dockerfiles and deployment Compose are required"
382
242
  validate_dockerignore "$dockerignore_file"
383
-
384
- today="$(date +%Y%m%d)"
385
- version="$(release_ledger next "$agents_file" "$today")"
386
- assert_version "$version"
387
- frontend_image="${PROJECT_NAME}_frontend:$version"
388
- backend_image="${PROJECT_NAME}_backend:$version"
389
-
243
+ today="$(date +%Y%m%d)"; version="$(release_ledger next "$agents_file" "$today")"; assert_version "$version"
244
+ frontend_image="${PROJECT_NAME}_frontend:$version"; backend_image="${PROJECT_NAME}_backend:$version"
390
245
  echo "release gate: pnpm test"; pnpm test
391
246
  echo "release gate: pnpm lint"; pnpm lint
392
247
  echo "release gate: pnpm typecheck"; pnpm typecheck
393
248
  echo "release gate: pnpm build"; RELEASE_VERSION="$version" pnpm build
394
-
395
- echo "release gate: frontend docker build"
396
- docker build --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" \
397
- --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
398
- echo "release gate: backend docker build"
399
- docker build --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" \
400
- --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
249
+ docker build --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
250
+ docker build --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
401
251
  [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$frontend_image")" == "$version" ]] || fail "frontend image version label mismatch"
402
252
  [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$backend_image")" == "$version" ]] || fail "backend image version label mismatch"
403
-
404
- staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
405
- output_root="$staging_parent/deploy"
406
- version_root="$output_root/$version"
407
- agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
408
- mkdir -p "$version_root/docker" "$version_root/nginx"
409
- install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"
410
- install -m 0600 "$project_root/.env" "$output_root/.env"
253
+ staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"; output_root="$staging_parent/deploy"; version_root="$output_root/$version"; agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
254
+ mkdir -p "$version_root/docker"; install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"; install -m 0600 "$project_root/.env" "$output_root/.env"
411
255
  printf 'RELEASE_VERSION=%s\n' "$version" > "$output_root/release.env"
412
- docker save -o "$version_root/frontend-image.tar" "$frontend_image"
413
- docker save -o "$version_root/backend-image.tar" "$backend_image"
414
- install -m 0644 "$compose_source" "$version_root/docker/docker-compose.blue-green.yml"
415
- install -m 0644 "$nginx_source" "$version_root/nginx/site.conf"
416
- cmp -s "$compose_source" "$version_root/docker/docker-compose.blue-green.yml" || fail "generated Compose is not deterministic"
417
- cmp -s "$nginx_source" "$version_root/nginx/site.conf" || fail "generated Nginx config is not deterministic"
418
- create_manifest "$version_root"
419
- validate_local_package "$output_root" "$version"
420
-
421
- raw_time="$(date '+%Y-%m-%d %H:%M:%S %z')"
422
- build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
256
+ docker save -o "$version_root/frontend-image.tar" "$frontend_image"; docker save -o "$version_root/backend-image.tar" "$backend_image"
257
+ install -m 0644 "$compose_source" "$version_root/docker/docker-compose.yml"; cmp -s "$compose_source" "$version_root/docker/docker-compose.yml" || fail "generated Compose is not deterministic"
258
+ create_manifest "$version_root"; validate_local_package "$output_root" "$version"
259
+ raw_time="$(date '+%Y-%m-%d %H:%M:%S %z')"; build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
423
260
  release_ledger prepare "$agents_file" "$version" "$build_time" "$agents_prepared"
424
-
425
- if [[ -e "$project_root/deploy" ]]; then
426
- old_deploy="$project_root/.deploy.previous.$$"
427
- [[ ! -e "$old_deploy" ]] || fail "temporary deploy replacement path already exists"
428
- mv "$project_root/deploy" "$old_deploy"
429
- fi
430
- if ! mv "$output_root" "$project_root/deploy"; then
431
- [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"
432
- fail "failed to publish deploy directory"
433
- fi
434
- if ! mv "$agents_prepared" "$agents_file"; then
435
- rm -rf -- "$project_root/deploy"
436
- [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"
437
- fail "failed to write AGENTS.md release record"
438
- fi
261
+ if [[ -e "$project_root/deploy" ]]; then old_deploy="$project_root/.deploy.previous.$$"; [[ ! -e "$old_deploy" ]] || fail "temporary deploy replacement path already exists"; mv "$project_root/deploy" "$old_deploy"; fi
262
+ if ! mv "$output_root" "$project_root/deploy"; then [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"; fail "failed to publish deploy directory"; fi
263
+ if ! mv "$agents_prepared" "$agents_file"; then rm -rf -- "$project_root/deploy"; [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"; fail "failed to write AGENTS.md release record"; fi
439
264
  [[ -n "$old_deploy" ]] && rm -rf -- "$old_deploy" && old_deploy=
440
- rm -rf -- "$staging_parent"; staging_parent=
441
- release_build_lock; BUILD_LOCK=
442
- trap - EXIT
265
+ rm -rf -- "$staging_parent"; staging_parent=; release_build_lock; BUILD_LOCK=; trap - EXIT
443
266
  echo "release package created: deploy/$version"
444
- return 0
445
267
  }
446
268
 
447
269
  read_release_pointer() {
448
270
  local file="$DEPLOY_ROOT/release.env" line
449
271
  [[ -f "$file" && "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
450
- line="$(cat "$file")"
451
- [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
452
- RELEASE_VERSION="${BASH_REMATCH[1]}"
453
- assert_version "$RELEASE_VERSION"
272
+ line="$(cat "$file")"; [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
273
+ RELEASE_VERSION="${BASH_REMATCH[1]}"; assert_version "$RELEASE_VERSION"
454
274
  }
455
275
 
456
276
  initialize_server_context() {
457
- DEPLOY_ROOT="$SCRIPT_DIR"
458
- load_dotenv "$DEPLOY_ROOT/.env"
459
- validate_configuration
460
- STATE_DIR="$DEPLOY_ROOT/state"
461
- ACTIVE_STATE="$STATE_DIR/active.env"
462
- PREVIOUS_STATE="$STATE_DIR/previous.env"
463
- NGINX_DIR="$DEPLOY_ROOT/nginx"
464
- NGINX_SITE_FILE="$NGINX_DIR/site.conf"
465
- UPSTREAM_FILE="$NGINX_DIR/active-upstreams.conf"
466
- FRONTEND_UPSTREAM_NAME="${PROJECT_NAME}_frontend_active"
467
- BACKEND_UPSTREAM_NAME="${PROJECT_NAME}_backend_active"
468
- FRONTEND_IMAGE_REPOSITORY="${PROJECT_NAME}_frontend"
469
- BACKEND_IMAGE_REPOSITORY="${PROJECT_NAME}_backend"
470
- for command in docker curl nginx tar find sort cmp awk sed grep uniq flock; do require_command "$command"; done
471
- select_checksum_tool
277
+ DEPLOY_ROOT="$SCRIPT_DIR"; load_dotenv "$DEPLOY_ROOT/.env"; validate_configuration; read_release_pointer
278
+ for command in docker curl tar find sort awk sed grep; do require_command "$command"; done
279
+ select_checksum_tool; mkdir -p "$DEPLOY_ROOT/logs"
472
280
  }
473
281
 
474
- read_state_file() {
475
- local file="$1" line key value color= version= count=0
476
- [[ -f "$file" ]] || return 1
477
- while IFS= read -r line || [[ -n "$line" ]]; do
478
- [[ "$line" =~ ^(ACTIVE_COLOR|ACTIVE_VERSION)=([A-Za-z0-9_-]+)$ ]] || fail "invalid state file: $file"
479
- key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"; count=$((count + 1))
480
- if [[ "$key" == ACTIVE_COLOR ]]; then [[ -z "$color" ]] || fail "duplicate state color"; color="$value"; else [[ -z "$version" ]] || fail "duplicate state version"; version="$value"; fi
481
- done < "$file"
482
- [[ "$count" -eq 2 && ( "$color" == blue || "$color" == green ) ]] || fail "incomplete state file: $file"
483
- assert_version "$version"
484
- STATE_COLOR="$color"; STATE_VERSION="$version"
282
+ record_operation() {
283
+ local result="$1" timestamp
284
+ [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
285
+ timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
286
+ printf '%s version=%s operation=%s result=%s\n' "$timestamp" "${RELEASE_VERSION:-unknown}" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
485
287
  }
486
288
 
487
- read_active() {
488
- ACTIVE_COLOR=; ACTIVE_VERSION=
489
- if read_state_file "$ACTIVE_STATE"; then ACTIVE_COLOR="$STATE_COLOR"; ACTIVE_VERSION="$STATE_VERSION"; fi
289
+ validate_uploaded_package() {
290
+ local root="$DEPLOY_ROOT/$RELEASE_VERSION"
291
+ [[ -f "$root/frontend-image.tar" && -f "$root/backend-image.tar" && -f "$root/docker/docker-compose.yml" ]] || fail "uploaded dual image package is incomplete"
292
+ [[ ! -e "$root/database" ]] || fail "release package must not contain database migration files"
293
+ reject_version_secrets "$root"; verify_manifest "$root"
294
+ tar -tf "$root/frontend-image.tar" >/dev/null || fail "invalid frontend image archive"
295
+ tar -tf "$root/backend-image.tar" >/dev/null || fail "invalid backend image archive"
490
296
  }
491
297
 
492
- read_previous() {
493
- PREVIOUS_COLOR=; PREVIOUS_VERSION=
494
- if read_state_file "$PREVIOUS_STATE"; then PREVIOUS_COLOR="$STATE_COLOR"; PREVIOUS_VERSION="$STATE_VERSION"; fi
298
+ archive_has_tag() {
299
+ local archive="$1" tag="$2" manifest compact count
300
+ manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
301
+ compact="$(tr -d '[:space:]' <<< "$manifest")"; count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
302
+ [[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly one expected tag: $tag"
495
303
  }
496
304
 
497
- write_state_candidate() {
498
- local file="$1" color="$2" version="$3"
499
- printf 'ACTIVE_COLOR=%s\nACTIVE_VERSION=%s\n' "$color" "$version" > "$file"
305
+ image_names() { FRONTEND_IMAGE="${PROJECT_NAME}_frontend:$RELEASE_VERSION"; BACKEND_IMAGE="${PROJECT_NAME}_backend:$RELEASE_VERSION"; export FRONTEND_IMAGE BACKEND_IMAGE RELEASE_VERSION; }
306
+
307
+ load_frontend_image() {
308
+ local archive="$DEPLOY_ROOT/$RELEASE_VERSION/frontend-image.tar"
309
+ archive_has_tag "$archive" "$FRONTEND_IMAGE"; docker load -i "$archive" >/dev/null
310
+ [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$FRONTEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "frontend loaded image version mismatch"
500
311
  }
501
312
 
502
- compose_file_for() {
503
- local version="$1"
504
- assert_version "$version"
505
- printf '%s/%s/docker/docker-compose.blue-green.yml\n' "$DEPLOY_ROOT" "$version"
313
+ load_backend_image() {
314
+ local archive="$DEPLOY_ROOT/$RELEASE_VERSION/backend-image.tar"
315
+ archive_has_tag "$archive" "$BACKEND_IMAGE"; docker load -i "$archive" >/dev/null
316
+ [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$BACKEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "backend loaded image version mismatch"
506
317
  }
507
318
 
508
- image_names_for() {
509
- local version="$1"
510
- assert_version "$version"
511
- FRONTEND_IMAGE="${FRONTEND_IMAGE_REPOSITORY}:$version"
512
- BACKEND_IMAGE="${BACKEND_IMAGE_REPOSITORY}:$version"
319
+ compose() {
320
+ FRONTEND_IMAGE="$FRONTEND_IMAGE" BACKEND_IMAGE="$BACKEND_IMAGE" RELEASE_VERSION="$RELEASE_VERSION" docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/$RELEASE_VERSION/docker/docker-compose.yml" "$@"
513
321
  }
514
322
 
515
- validate_uploaded_package() {
516
- local package_root="$1"
517
- [[ -f "$package_root/frontend-image.tar" && -f "$package_root/backend-image.tar" ]] || fail "dual image archives are missing"
518
- [[ -f "$package_root/docker/docker-compose.blue-green.yml" && -f "$package_root/nginx/site.conf" ]] || fail "release configuration is incomplete"
519
- [[ ! -e "$package_root/database" ]] || fail "release package must not contain database migration files"
520
- reject_version_secrets "$package_root"
521
- verify_manifest "$package_root"
522
- tar -tf "$package_root/frontend-image.tar" >/dev/null || fail "invalid frontend image archive"
523
- tar -tf "$package_root/backend-image.tar" >/dev/null || fail "invalid backend image archive"
323
+ verify_service() {
324
+ local service="$1" port health version_url actual
325
+ if [[ "$service" == frontend ]]; then port="$FRONTEND_PORT"; else port="$BACKEND_PORT"; fi
326
+ health="$(endpoint_url "$DOCKER_BIND_IP" "$port" /health)"; version_url="$(endpoint_url "$DOCKER_BIND_IP" "$port" /version)"
327
+ curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$health" >/dev/null
328
+ actual="$(curl --fail --silent --show-error "$version_url")"
329
+ [[ "$actual" == "$RELEASE_VERSION" ]] || fail "$service version mismatch: expected $RELEASE_VERSION, got $actual"
524
330
  }
525
331
 
526
- archive_has_tag() {
527
- local archive="$1" tag="$2" manifest compact repo_tag_count
528
- manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
529
- compact="$(tr -d '[:space:]' <<< "$manifest")"
530
- repo_tag_count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
531
- [[ "$repo_tag_count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] ||
532
- fail "image archive must contain exactly one expected tag: $tag"
533
- }
534
-
535
- load_release_images() {
536
- local version="$1" package_root="$DEPLOY_ROOT/$1"
537
- image_names_for "$version"
538
- archive_has_tag "$package_root/frontend-image.tar" "$FRONTEND_IMAGE"
539
- archive_has_tag "$package_root/backend-image.tar" "$BACKEND_IMAGE"
540
- docker load -i "$package_root/frontend-image.tar" >/dev/null
541
- docker load -i "$package_root/backend-image.tar" >/dev/null
542
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$FRONTEND_IMAGE")" == "$version" ]] || fail "frontend loaded image version mismatch"
543
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$BACKEND_IMAGE")" == "$version" ]] || fail "backend loaded image version mismatch"
544
- }
545
-
546
- compose_for() {
547
- local version="$1"; shift
548
- local compose_file
549
- image_names_for "$version"
550
- compose_file="$(compose_file_for "$version")"
551
- [[ -f "$compose_file" ]] || fail "compose file is missing for version $version"
552
- FRONTEND_IMAGE="$FRONTEND_IMAGE" BACKEND_IMAGE="$BACKEND_IMAGE" \
553
- docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$compose_file" "$@"
554
- }
555
-
556
- up_pair() {
557
- local color="$1" version="$2"
558
- compose_for "$version" up -d --force-recreate "frontend-$color" "backend-$color"
559
- }
560
-
561
- verify_pair() {
562
- local color="$1" version="$2" frontend_health frontend_version backend_health backend_version frontend_actual backend_actual
563
- if [[ "$color" == blue ]]; then
564
- frontend_health="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_BLUE_PORT" /health)"; frontend_version="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_BLUE_PORT" /version)"
565
- backend_health="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_BLUE_PORT" /health)"; backend_version="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_BLUE_PORT" /version)"
566
- else
567
- frontend_health="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_GREEN_PORT" /health)"; frontend_version="$(endpoint_url "$DOCKER_BIND_IP" "$FRONTEND_GREEN_PORT" /version)"
568
- backend_health="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_GREEN_PORT" /health)"; backend_version="$(endpoint_url "$DOCKER_BIND_IP" "$BACKEND_GREEN_PORT" /version)"
569
- fi
570
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$frontend_health" >/dev/null
571
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$backend_health" >/dev/null
572
- frontend_actual="$(curl --fail --silent --show-error "$frontend_version")"
573
- backend_actual="$(curl --fail --silent --show-error "$backend_version")"
574
- [[ "$frontend_actual" == "$version" ]] || fail "frontend version mismatch: expected $version, got $frontend_actual"
575
- [[ "$backend_actual" == "$version" ]] || fail "backend version mismatch: expected $version, got $backend_actual"
576
- }
577
-
578
- render_site_candidate() {
579
- local source="$1" output="$2" escaped_root
580
- for placeholder in __PROJECT_NAME__ __PROJECT_HTTP_PORT__ __DEPLOY_ROOT__ __FRONTEND_UPSTREAM_NAME__ __BACKEND_UPSTREAM_NAME__; do
581
- grep -Fq "$placeholder" "$source" || fail "Nginx template is missing $placeholder"
582
- done
583
- escaped_root="${DEPLOY_ROOT//\\/\\\\}"; escaped_root="${escaped_root//&/\\&}"; escaped_root="${escaped_root//|/\\|}"
584
- sed -e "s|__PROJECT_NAME__|$PROJECT_NAME|g" -e "s|__PROJECT_HTTP_PORT__|$PROJECT_HTTP_PORT|g" \
585
- -e "s|__DEPLOY_ROOT__|$escaped_root|g" -e "s|__FRONTEND_UPSTREAM_NAME__|$FRONTEND_UPSTREAM_NAME|g" \
586
- -e "s|__BACKEND_UPSTREAM_NAME__|$BACKEND_UPSTREAM_NAME|g" "$source" > "$output"
587
- }
588
-
589
- write_upstream_candidate() {
590
- local color="$1" output="$2" frontend_port backend_port
591
- if [[ "$color" == blue ]]; then frontend_port="$FRONTEND_BLUE_PORT"; backend_port="$BACKEND_BLUE_PORT"; else frontend_port="$FRONTEND_GREEN_PORT"; backend_port="$BACKEND_GREEN_PORT"; fi
592
- printf 'upstream %s {\n server %s:%s;\n keepalive 32;\n}\n\nupstream %s {\n server %s:%s;\n keepalive 32;\n}\n' \
593
- "$FRONTEND_UPSTREAM_NAME" "$DOCKER_BIND_IP" "$frontend_port" \
594
- "$BACKEND_UPSTREAM_NAME" "$DOCKER_BIND_IP" "$backend_port" > "$output"
595
- }
596
-
597
- restore_file() {
598
- local backup="$1" target="$2"
599
- if [[ -f "$backup" ]]; then cp -p "$backup" "$target"; else rm -f -- "$target"; fi
600
- }
601
-
602
- commit_traffic_and_state() {
603
- local site_candidate="$1" upstream_candidate="$2" active_candidate="$3" previous_candidate="${4:-}" expected_version="$5"
604
- local transaction public_health public_version_url public_version rollback_failed=0
605
- mkdir -p "$NGINX_DIR" "$STATE_DIR"
606
- transaction="$(mktemp -d "$DEPLOY_ROOT/.nginx-switch.XXXXXX")"
607
- [[ -f "$NGINX_SITE_FILE" ]] && cp -p "$NGINX_SITE_FILE" "$transaction/site.old"
608
- [[ -f "$UPSTREAM_FILE" ]] && cp -p "$UPSTREAM_FILE" "$transaction/upstreams.old"
609
- [[ -f "$ACTIVE_STATE" ]] && cp -p "$ACTIVE_STATE" "$transaction/active.old"
610
- [[ -f "$PREVIOUS_STATE" ]] && cp -p "$PREVIOUS_STATE" "$transaction/previous.old"
611
-
612
- exec 9>/tmp/wdyy-nginx-deploy.lock
613
- flock -x 9
614
- install -m 0644 "$site_candidate" "$NGINX_SITE_FILE"
615
- install -m 0644 "$upstream_candidate" "$UPSTREAM_FILE"
616
- if ! nginx -t; then
617
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
618
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
619
- rm -rf -- "$transaction"
620
- fail "Nginx candidate validation failed; project configuration restored"
621
- fi
622
- if ! nginx -s reload; then
623
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
624
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
625
- nginx -t && nginx -s reload || rollback_failed=1
626
- rm -rf -- "$transaction"
627
- [[ "$rollback_failed" -eq 0 ]] || fail "Nginx reload failed and old project configuration could not be restored"
628
- fail "Nginx reload failed; old project configuration restored"
629
- fi
332
+ run_or_replace() {
333
+ local target="$1"
334
+ validate_uploaded_package; image_names
335
+ case "$target" in
336
+ frontend) load_frontend_image; compose up -d --force-recreate --no-deps frontend; verify_service frontend ;;
337
+ backend) load_backend_image; compose up -d --force-recreate --no-deps backend; verify_service backend ;;
338
+ both) load_frontend_image; load_backend_image; compose up -d --force-recreate frontend backend; verify_service frontend; verify_service backend ;;
339
+ *) fail "invalid deployment target: $target" ;;
340
+ esac
341
+ }
630
342
 
631
- public_health="$(endpoint_url "$DOCKER_BIND_IP" "$PROJECT_HTTP_PORT" /health)"
632
- public_version_url="$(endpoint_url "$DOCKER_BIND_IP" "$PROJECT_HTTP_PORT" /api/version)"
633
- if ! curl --fail --silent --show-error --retry 6 --retry-delay 1 --retry-connrefused "$public_health" >/dev/null ||
634
- ! public_version="$(curl --fail --silent --show-error "$public_version_url")" ||
635
- [[ "$public_version" != "$expected_version" ]]; then
636
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
637
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
638
- nginx -t && nginx -s reload || rollback_failed=1
639
- rm -rf -- "$transaction"
640
- [[ "$rollback_failed" -eq 0 ]] || fail "public endpoint verification failed and old project traffic could not be restored"
641
- fail "public endpoint verification failed; old project traffic restored"
642
- fi
343
+ stop_services() { image_names; compose stop frontend backend; }
643
344
 
644
- if ! install -m 0644 "$active_candidate" "$ACTIVE_STATE" || { [[ -n "$previous_candidate" ]] && ! install -m 0644 "$previous_candidate" "$PREVIOUS_STATE"; }; then
645
- restore_file "$transaction/site.old" "$NGINX_SITE_FILE"
646
- restore_file "$transaction/upstreams.old" "$UPSTREAM_FILE"
647
- restore_file "$transaction/active.old" "$ACTIVE_STATE"
648
- restore_file "$transaction/previous.old" "$PREVIOUS_STATE"
649
- nginx -t && nginx -s reload || rollback_failed=1
650
- rm -rf -- "$transaction"
651
- [[ "$rollback_failed" -eq 0 ]] || fail "state commit failed and old traffic could not be restored"
652
- fail "state commit failed; old project traffic and state restored"
653
- fi
654
- [[ -n "$previous_candidate" ]] || rm -f -- "$PREVIOUS_STATE"
655
- rm -rf -- "$transaction"
656
- flock -u 9
657
- exec 9>&-
658
- }
659
-
660
- deploy_release() {
661
- local mode="$1" package_root target_color work site_candidate upstream_candidate active_candidate previous_candidate=
662
- read_release_pointer
663
- package_root="$DEPLOY_ROOT/$RELEASE_VERSION"
664
- validate_uploaded_package "$package_root"
665
- read_active
666
- if [[ "$mode" == start ]]; then
667
- [[ -z "$ACTIVE_VERSION" ]] || fail "an active deployment already exists; use replace"
668
- target_color=blue
669
- else
670
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment exists; use start"
671
- [[ "$RELEASE_VERSION" != "$ACTIVE_VERSION" ]] || fail "replace version must differ from active version"
672
- target_color=$([[ "$ACTIVE_COLOR" == blue ]] && printf green || printf blue)
673
- fi
674
- mkdir -p "$STATE_DIR" "$NGINX_DIR" "$DEPLOY_ROOT/logs"
675
- load_release_images "$RELEASE_VERSION"
676
- up_pair "$target_color" "$RELEASE_VERSION"
677
- verify_pair "$target_color" "$RELEASE_VERSION"
678
-
679
- work="$(mktemp -d "$DEPLOY_ROOT/.traffic-candidate.XXXXXX")"
680
- site_candidate="$work/site.conf"; upstream_candidate="$work/upstreams.conf"; active_candidate="$work/active.env"
681
- render_site_candidate "$package_root/nginx/site.conf" "$site_candidate"
682
- write_upstream_candidate "$target_color" "$upstream_candidate"
683
- write_state_candidate "$active_candidate" "$target_color" "$RELEASE_VERSION"
684
- if [[ -n "$ACTIVE_VERSION" ]]; then
685
- previous_candidate="$work/previous.env"
686
- write_state_candidate "$previous_candidate" "$ACTIVE_COLOR" "$ACTIVE_VERSION"
687
- fi
688
- trap 'rm -rf -- "${work:-}"' EXIT
689
- commit_traffic_and_state "$site_candidate" "$upstream_candidate" "$active_candidate" "$previous_candidate" "$RELEASE_VERSION"
690
- rm -rf -- "$work"
691
- trap - EXIT
692
- echo "active release: $RELEASE_VERSION ($target_color)"
693
- return 0
694
- }
695
-
696
- restart_current() {
697
- read_active
698
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment"
699
- validate_uploaded_package "$DEPLOY_ROOT/$ACTIVE_VERSION"
700
- load_release_images "$ACTIVE_VERSION"
701
- up_pair "$ACTIVE_COLOR" "$ACTIVE_VERSION"
702
- verify_pair "$ACTIVE_COLOR" "$ACTIVE_VERSION"
703
- }
704
-
705
- stop_current() {
706
- read_active
707
- [[ -n "$ACTIVE_VERSION" ]] || fail "no active deployment"
708
- compose_for "$ACTIVE_VERSION" stop "frontend-$ACTIVE_COLOR" "backend-$ACTIVE_COLOR"
709
- }
710
-
711
- status_current() {
712
- read_active; read_previous
713
- printf 'active_color=%s\nactive_version=%s\nprevious_color=%s\nprevious_version=%s\n' "$ACTIVE_COLOR" "$ACTIVE_VERSION" "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
714
- if [[ -n "$ACTIVE_VERSION" ]]; then
715
- compose_for "$ACTIVE_VERSION" ps
716
- fi
345
+ server_menu() {
346
+ local choice status
347
+ initialize_server_context
348
+ printf '%s\n' '1. 运行或替换前端' '2. 运行或替换后端' '3. 运行或替换前后端' '4. 停止'
349
+ IFS= read -r choice || fail "failed to read deployment menu selection"
350
+ case "$choice" in
351
+ 1) OPERATION=run-or-replace-frontend ;;
352
+ 2) OPERATION=run-or-replace-backend ;;
353
+ 3) OPERATION=run-or-replace-both ;;
354
+ 4) OPERATION=stop-both ;;
355
+ *) OPERATION=invalid-selection; record_operation failure; fail "invalid deployment menu selection: $choice" ;;
356
+ esac
357
+ trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
358
+ case "$choice" in
359
+ 1) run_or_replace frontend ;;
360
+ 2) run_or_replace backend ;;
361
+ 3) run_or_replace both ;;
362
+ 4) stop_services ;;
363
+ esac
717
364
  }
718
365
 
719
- remove_project_docker() {
720
- local value container_output network_output image_output
721
- local -a container_ids=() network_ids=() image_references=()
722
- container_output="$(docker ps -aq --filter "label=com.docker.compose.project=$PROJECT_NAME")"
723
- while IFS= read -r value; do [[ -n "$value" ]] && container_ids+=("$value"); done <<< "$container_output"
724
- ((${#container_ids[@]} == 0)) || docker rm -f "${container_ids[@]}"
725
- network_output="$(docker network ls -q --filter "label=com.docker.compose.project=$PROJECT_NAME")"
726
- while IFS= read -r value; do [[ -n "$value" ]] && network_ids+=("$value"); done <<< "$network_output"
727
- ((${#network_ids[@]} == 0)) || docker network rm "${network_ids[@]}"
728
- image_output="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | awk -F: -v frontend="$FRONTEND_IMAGE_REPOSITORY" -v backend="$BACKEND_IMAGE_REPOSITORY" '($1 == frontend || $1 == backend) && $2 != "<none>" { print $0 }' | LC_ALL=C sort -u)"
729
- while IFS= read -r value; do [[ -n "$value" ]] && image_references+=("$value"); done <<< "$image_output"
730
- ((${#image_references[@]} == 0)) || docker image rm -f "${image_references[@]}"
731
- echo "removed only Docker resources for project: $PROJECT_NAME"
732
- }
733
-
734
- rollback_version() {
735
- local requested="$1" work site_candidate upstream_candidate active_candidate previous_candidate
736
- assert_version "$requested"
737
- read_active; read_previous
738
- [[ -n "$ACTIVE_VERSION" && -n "$PREVIOUS_VERSION" ]] || fail "active and previous deployment states are required"
739
- [[ "$requested" == "$PREVIOUS_VERSION" ]] || fail "rollback target must equal previous version $PREVIOUS_VERSION"
740
- verify_pair "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
741
- work="$(mktemp -d "$DEPLOY_ROOT/.rollback-candidate.XXXXXX")"
742
- site_candidate="$work/site.conf"; upstream_candidate="$work/upstreams.conf"; active_candidate="$work/active.env"; previous_candidate="$work/previous.env"
743
- render_site_candidate "$DEPLOY_ROOT/$PREVIOUS_VERSION/nginx/site.conf" "$site_candidate"
744
- write_upstream_candidate "$PREVIOUS_COLOR" "$upstream_candidate"
745
- write_state_candidate "$active_candidate" "$PREVIOUS_COLOR" "$PREVIOUS_VERSION"
746
- write_state_candidate "$previous_candidate" "$ACTIVE_COLOR" "$ACTIVE_VERSION"
747
- trap 'rm -rf -- "${work:-}"' EXIT
748
- commit_traffic_and_state "$site_candidate" "$upstream_candidate" "$active_candidate" "$previous_candidate" "$PREVIOUS_VERSION"
749
- rm -rf -- "$work"
750
- trap - EXIT
751
- }
752
-
753
- case "${1:-}" in
754
- build) [[ "$#" -eq 1 ]] || usage; build_release ;;
755
- start) [[ "$#" -eq 1 ]] || usage; initialize_server_context; deploy_release start ;;
756
- replace) [[ "$#" -eq 1 ]] || usage; initialize_server_context; deploy_release replace ;;
757
- restart) [[ "$#" -eq 1 ]] || usage; initialize_server_context; restart_current ;;
758
- stop) [[ "$#" -eq 1 ]] || usage; initialize_server_context; stop_current ;;
759
- remove) [[ "$#" -eq 1 ]] || usage; initialize_server_context; remove_project_docker ;;
760
- status) [[ "$#" -eq 1 ]] || usage; initialize_server_context; status_current ;;
761
- rollback) [[ "$#" -eq 2 ]] || usage; initialize_server_context; rollback_version "$2" ;;
762
- *) usage ;;
763
- esac
366
+ if [[ "$#" -eq 0 ]]; then server_menu
367
+ elif [[ "$#" -eq 1 && "$1" == build ]]; then build_release
368
+ else usage
369
+ fi