gowalk-cicd 1.0.175 → 1.0.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -521,6 +521,9 @@ The backend message is `gowalk-cicd/backend-deploy-failed.v1 phase=<phase> exit_
521
521
  fixed fields without JSON braces, which GitHub can mask when a multiline secret has brace-only lines.
522
522
  Consumers validate the complete envelope, the known phase and exit code 1–255; older JSON messages
523
523
  remain readable by the panel. Masked fields are never reconstructed and credential masking stays enabled.
524
+ Protected vhost failures add `backend_vhost_failed` (`gowalk-cicd/backend-vhost-failed.v1`) with fixed
525
+ brace-free `operation`, `stage`, `reason`, `child_exit` and `signals`; see `docs/BACKEND_EDGE.md`.
526
+ It preserves the existing refusal and records only bounded observed facts, never private child output.
524
527
  An interrupted locked Pod fetch can retry once within the existing 900-second install budget.
525
528
  `native_pods_retry` (`gowalk-cicd/native-pods-retry.v1`) reports `attempt:2` and `reason:partial_transfer`;
526
529
  successful preparation reports `pod_attempts`. Dependency locks, relay and TLS checks remain enforced.
@@ -8,7 +8,8 @@ const apple = require('./reconcile_apple_upload.cjs');
8
8
  const handoff = require('./apple_upload_handoff.cjs');
9
9
 
10
10
  function credential(env = process.env) {
11
- const pem = env.ASC_KEY_P8 || (env.ASC_KEY_PATH ? fs.readFileSync(env.ASC_KEY_PATH, 'utf8') : '');
11
+ const keyPath = env.ASC_KEY_PATH || env.ASC_KEY_P8_PATH;
12
+ const pem = env.ASC_KEY_P8 || (keyPath ? fs.readFileSync(keyPath, 'utf8') : '');
12
13
  if (!pem || !env.ASC_KEY_ID || !env.ASC_ISSUER_ID) handoff.fail('credential_missing');
13
14
  return {pem, id: env.ASC_KEY_ID, issuer: env.ASC_ISSUER_ID};
14
15
  }
@@ -207,6 +207,7 @@ runs:
207
207
  "${{ github.action_path }}/runtime_env.py" \
208
208
  "${{ github.action_path }}/runtime_env_parse.py" \
209
209
  "${{ github.action_path }}/edge_vhost.py" \
210
+ "${{ github.action_path }}/vhost_diagnostics.py" \
210
211
  "${{ github.action_path }}/edge_health.py" \
211
212
  "${{ github.action_path }}/deploy_evidence.sh" \
212
213
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
@@ -7,6 +7,7 @@ import sys
7
7
 
8
8
  from configure_vhost import content, replace
9
9
  from edge_inputs import load
10
+ from vhost_diagnostics import observing
10
11
 
11
12
  NGINX_DIR = Path("/etc/nginx/conf.d")
12
13
 
@@ -70,56 +71,67 @@ def edge_content(text, domain, port, include, stage="update"):
70
71
 
71
72
 
72
73
  def planned(config, app, domain, port, nginx_dir=NGINX_DIR, stage="update"):
73
- value = validate(config, app, domain, nginx_dir)
74
- if value["ingress_required"] != "true":
75
- raise ValueError("backend_ingress_configuration_required")
76
- legacy, edge, include = paths(config, app, nginx_dir)
77
- token = value["ingress_token"]
78
- guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
79
- 'proxy_set_header X-App-Robot-Backend-Token "";\n')
80
- files = {include: (guard.encode(), 0o600),
81
- edge: (edge_content(previous(edge), domain, port, include, stage).encode(), 0o644)}
82
- old_domain = recorded_domain(legacy)
83
- if old_domain:
84
- files[legacy] = (content(previous(legacy), old_domain, port).encode(),
85
- stat.S_IMODE(legacy.stat().st_mode))
86
- return files
74
+ with observing("validate", stage):
75
+ value = validate(config, app, domain, nginx_dir)
76
+ with observing("plan", stage):
77
+ if value["ingress_required"] != "true":
78
+ raise ValueError("backend_ingress_configuration_required")
79
+ legacy, edge, include = paths(config, app, nginx_dir)
80
+ token = value["ingress_token"]
81
+ guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
82
+ 'proxy_set_header X-App-Robot-Backend-Token "";\n')
83
+ files = {include: (guard.encode(), 0o600),
84
+ edge: (edge_content(previous(edge), domain, port, include, stage).encode(), 0o644)}
85
+ old_domain = recorded_domain(legacy)
86
+ if old_domain:
87
+ files[legacy] = (content(previous(legacy), old_domain, port).encode(),
88
+ stat.S_IMODE(legacy.stat().st_mode))
89
+ return files
87
90
 
88
91
 
89
92
  def configure(config, app, domain, port, nginx_dir=NGINX_DIR, run=subprocess.run, stage="update"):
90
93
  files = planned(config, app, domain, port, nginx_dir, stage)
91
- saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
92
- for path in files}
93
- changes = {path: value for path, value in files.items() if saved[path] != value}
94
+ with observing("plan", stage):
95
+ saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
96
+ for path in files}
97
+ changes = {path: value for path, value in files.items() if saved[path] != value}
94
98
  if not changes:
95
99
  return "unchanged"
96
100
  try:
97
- for path, (data, mode) in changes.items():
98
- replace(path, data, mode)
99
- run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
100
- run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
101
+ with observing("write", stage):
102
+ for path, (data, mode) in changes.items():
103
+ replace(path, data, mode)
104
+ with observing("nginx_test", stage):
105
+ run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
106
+ with observing("nginx_reload", stage):
107
+ run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
101
108
  except (OSError, subprocess.SubprocessError):
102
- for path in changes:
103
- if saved[path] is None:
104
- path.unlink(missing_ok=True)
105
- else:
106
- replace(path, *saved[path])
109
+ with observing("rollback", stage):
110
+ for path in changes:
111
+ if saved[path] is None:
112
+ path.unlink(missing_ok=True)
113
+ else:
114
+ replace(path, *saved[path])
107
115
  raise ValueError("backend_edge_nginx_failed") from None
108
116
  return "changed"
109
117
 
110
118
 
111
119
  def main():
112
- operation, config, app, domain = sys.argv[1:5]
120
+ stage = (sys.argv[6:7] or ["update"])[0]
121
+ with observing("validate", stage):
122
+ operation, config, app, domain = sys.argv[1:5]
123
+ if operation not in {"validate", "configure"}:
124
+ raise ValueError("backend_edge_operation_invalid")
125
+ port = int(sys.argv[5]) if operation == "configure" else None
113
126
  if operation == "validate":
114
- validate(config, app, domain)
127
+ with observing("validate", stage):
128
+ validate(config, app, domain)
115
129
  elif operation == "configure":
116
- print(configure(config, app, domain, int(sys.argv[5]), stage=(sys.argv[6:7] or ["update"])[0]))
117
- else:
118
- raise ValueError("backend_edge_operation_invalid")
130
+ print(configure(config, app, domain, port, stage=stage))
119
131
 
120
132
 
121
133
  if __name__ == "__main__":
122
134
  try:
123
135
  main()
124
- except (OSError, ValueError):
136
+ except (OSError, ValueError, IndexError):
125
137
  sys.exit("backend_edge_vhost_refused")
@@ -0,0 +1,90 @@
1
+ """Fixed vhost failure facts; private child output and exception text never leave this process."""
2
+ from contextlib import contextmanager
3
+ import errno
4
+ import subprocess
5
+ import sys
6
+
7
+ OPERATIONS = frozenset({"validate", "plan", "write", "nginx_test", "nginx_reload", "rollback"})
8
+ STAGES = frozenset({"update", "acme", "tls"})
9
+ REFUSALS = frozenset({
10
+ "backend_app_invalid", "backend_vhost_symlink", "backend_vhost_identity_invalid",
11
+ "backend_ingress_downgrade_refused", "backend_edge_domain_must_be_distinct", "backend_edge_domain_changed",
12
+ "backend_ingress_path_invalid", "backend_edge_vhost_invalid", "backend_edge_location_invalid",
13
+ "backend_ingress_configuration_required", "backend_edge_operation_invalid", "backend_edge_inputs_invalid",
14
+ "backend_ingress_required_invalid", "backend_build_sha_invalid", "backend_public_health_invalid",
15
+ "backend_public_base_invalid", "backend_public_health_base_mismatch", "backend_edge_config_symlink",
16
+ "backend_edge_config_invalid",
17
+ })
18
+ CONTENT_REFUSALS = {
19
+ "invalid domain, port or stage": "input_invalid",
20
+ "vhost belongs to another domain": "vhost_identity_invalid",
21
+ "managed transport directive is not unique": "transport_ambiguous",
22
+ "managed connection map is not recognized": "connection_map_invalid",
23
+ "managed upstream is not unique": "upstream_ambiguous",
24
+ }
25
+ FILESYSTEM = {
26
+ errno.ENOENT: "file_missing", errno.EACCES: "permission_denied", errno.EPERM: "permission_denied",
27
+ errno.EROFS: "filesystem_readonly", errno.ENOSPC: "storage_full", errno.EIO: "io_failure",
28
+ }
29
+ NGINX_SIGNALS = (
30
+ ("certificate_load", ("cannot load certificate",)),
31
+ ("certificate_key_mismatch", ("key values mismatch",)),
32
+ ("configuration_syntax", ("unknown directive", "unexpected end of file", "invalid number of arguments",
33
+ "is not allowed here", "unexpected \"}", "unexpected \";")),
34
+ ("listener_in_use", ("address already in use",)),
35
+ ("permission_denied", ("permission denied",)),
36
+ ("upstream_resolution", ("host not found in upstream",)),
37
+ )
38
+ OUTPUT_LIMIT = 16 * 1024
39
+
40
+
41
+ def classify(error):
42
+ if isinstance(error, subprocess.TimeoutExpired):
43
+ return "timeout", "none"
44
+ if isinstance(error, subprocess.CalledProcessError):
45
+ code = error.returncode
46
+ return "command_failed", str(code) if type(code) is int and -255 <= code <= 255 and code else "none"
47
+ if isinstance(error, OSError):
48
+ code = error.errno
49
+ return FILESYSTEM.get(code, "unclassified") if type(code) is int else "unclassified", "none"
50
+ message = error.args[0] if len(error.args) == 1 and type(error.args[0]) is str else ""
51
+ message = message if len(message) <= 96 else ""
52
+ return message if message in REFUSALS else CONTENT_REFUSALS.get(message, "unclassified"), "none"
53
+
54
+
55
+ def nginx_signals(error, operation):
56
+ if operation != "nginx_test" or not isinstance(error, subprocess.CalledProcessError):
57
+ return "none"
58
+ output = error.stderr
59
+ if type(output) is bytes:
60
+ output = output[:OUTPUT_LIMIT].decode("utf-8", errors="replace")
61
+ elif type(output) is str:
62
+ output = output[:OUTPUT_LIMIT].encode("utf-8", errors="replace")[:OUTPUT_LIMIT]
63
+ output = output.decode("utf-8", errors="replace")
64
+ else:
65
+ return "none"
66
+ output = output.lower()
67
+ signals = [code for code, patterns in NGINX_SIGNALS if any(pattern in output for pattern in patterns)]
68
+ return ",".join(signals) or "none"
69
+
70
+
71
+ def report(operation, stage, error):
72
+ operation = operation if type(operation) is str and operation in OPERATIONS else "unclassified"
73
+ stage = stage if type(stage) is str and stage in STAGES else "unclassified"
74
+ reason, child_exit = classify(error)
75
+ signals = nginx_signals(error, operation)
76
+ message = ("::error title=backend_vhost_failed::gowalk-cicd/backend-vhost-failed.v1 "
77
+ "operation={} stage={} reason={} child_exit={} signals={}\n")
78
+ try:
79
+ sys.stderr.write(message.format(operation, stage, reason, child_exit, signals))
80
+ except (OSError, ValueError):
81
+ pass # Diagnostic delivery must not replace the original refusal.
82
+
83
+
84
+ @contextmanager
85
+ def observing(operation, stage):
86
+ try:
87
+ yield
88
+ except (OSError, ValueError, IndexError, subprocess.SubprocessError) as error:
89
+ report(operation, stage, error)
90
+ raise
@@ -44,3 +44,16 @@ while preserving their domains, TLS settings and ingress checks.
44
44
  These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
45
45
  `public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
46
46
  by name. Deployments without these options retain public origin access, certificate handling and health checks.
47
+
48
+ Edge-vhost refusals also emit the fixed stderr annotation `backend_vhost_failed` with schema
49
+ `gowalk-cicd/backend-vhost-failed.v1`. Its brace-free fields are `operation`, `stage`, `reason`,
50
+ `child_exit` and `signals`. Operations are validate, plan, write, nginx_test, nginx_reload and rollback;
51
+ stages are update, acme and tls. Unknown metadata or reasons remain unclassified. `child_exit` is the
52
+ observed nonzero child status (-255 to 255), or `none`; it does not change the CLI's failed exit 1.
53
+ Reasons retain allowlisted planning refusals or fixed filesystem, timeout and command-failure facts.
54
+ Signals inspect only the first 16 KiB of failed nginx-test stderr and emit certificate_load,
55
+ certificate_key_mismatch, configuration_syntax, listener_in_use, permission_denied or upstream_resolution.
56
+ They are observed patterns, not a unique cause or retry permission. No raw error, command, host, path,
57
+ token or child output is included. A failed restoration adds a separate rollback refusal while keeping
58
+ the original failure visible; absence of that extra line alone does not prove successful restoration.
59
+ The generic `backend_edge_vhost_refused`, changed/unchanged stdout, ingress, TLS and deploy gates remain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.175",
3
+ "version": "1.0.177",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {