gowalk-cicd 1.0.196 → 1.0.198

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.
@@ -306,9 +306,11 @@ runs:
306
306
  run: |
307
307
  RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-runtime-diagnostics.XXXXXX")"
308
308
  echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
309
+ echo "transport=$RESULT_DIR/transport.json" >> "$GITHUB_OUTPUT"
309
310
  python3 "${{ github.action_path }}/runtime_diagnostics.py" \
310
311
  --app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --expected-build-sha "$DIAGNOSTIC_BUILD" \
311
- --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" --output "$RESULT_DIR/result.json"
312
+ --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" --output "$RESULT_DIR/result.json" \
313
+ --transport-output "$RESULT_DIR/transport.json"
312
314
 
313
315
  - name: Retain scoped backend runtime result
314
316
  if: always() && inputs.operation == 'runtime-diagnostics'
@@ -317,7 +319,9 @@ runs:
317
319
  NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
318
320
  with:
319
321
  name: backend-runtime-diagnostics-${{ github.job }}-${{ github.run_attempt }}
320
- path: ${{ steps.runtime_diagnostics.outputs.result }}
322
+ path: |
323
+ ${{ steps.runtime_diagnostics.outputs.result }}
324
+ ${{ steps.runtime_diagnostics.outputs.transport }}
321
325
  if-no-files-found: error
322
326
 
323
327
  - name: Read scoped backend network prerequisites
@@ -40,7 +40,7 @@ class ReaderFailure(Exception):
40
40
  super().__init__(self.code)
41
41
 
42
42
 
43
- def transfer(selector, process, payload, offset, data):
43
+ def transfer(selector, process, payload, offset, data, errors):
44
44
  for key, _ in selector.select(0.1):
45
45
  if key.fileobj is process.stdin:
46
46
  offset += os.write(process.stdin.fileno(), payload[offset:offset + 4096])
@@ -48,23 +48,27 @@ def transfer(selector, process, payload, offset, data):
48
48
  selector.unregister(process.stdin)
49
49
  process.stdin.close()
50
50
  else:
51
- chunk = os.read(process.stdout.fileno(), 65536)
51
+ chunk = os.read(key.fileobj.fileno(), 65536)
52
52
  if not chunk:
53
- selector.unregister(process.stdout)
54
- data.extend(chunk)
55
- if len(data) > LIMIT:
53
+ selector.unregister(key.fileobj)
54
+ (errors if key.fileobj is process.stderr else data).extend(chunk)
55
+ if len(data) + len(errors) > LIMIT:
56
56
  raise ReaderFailure('output_limit')
57
57
  return offset
58
58
 
59
59
 
60
- def bounded(argv, *, payload=None, seconds=30, with_status=False):
60
+ def bounded(argv, *, payload=None, seconds=30, with_status=False, with_stderr=False):
61
61
  """Bound output, runtime and owned process cleanup without printing stderr."""
62
62
  process = subprocess.Popen(argv, stdin=subprocess.PIPE if payload else subprocess.DEVNULL,
63
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True)
64
- deadline, data = time.monotonic() + seconds, bytearray()
63
+ stdout=subprocess.PIPE,
64
+ stderr=subprocess.PIPE if with_stderr else subprocess.DEVNULL,
65
+ start_new_session=True)
66
+ deadline, data, errors = time.monotonic() + seconds, bytearray(), bytearray()
65
67
  try:
66
68
  with selectors.DefaultSelector() as selector:
67
69
  selector.register(process.stdout, selectors.EVENT_READ)
70
+ if with_stderr:
71
+ selector.register(process.stderr, selectors.EVENT_READ)
68
72
  if payload:
69
73
  os.set_blocking(process.stdin.fileno(), False)
70
74
  selector.register(process.stdin, selectors.EVENT_WRITE)
@@ -72,12 +76,14 @@ def bounded(argv, *, payload=None, seconds=30, with_status=False):
72
76
  while selector.get_map():
73
77
  if time.monotonic() >= deadline:
74
78
  raise ReaderFailure('command_timeout')
75
- offset = transfer(selector, process, payload, offset, data)
79
+ offset = transfer(selector, process, payload, offset, data, errors)
76
80
  try:
77
81
  status = process.wait(timeout=max(0.01, deadline - time.monotonic()))
78
82
  except subprocess.TimeoutExpired:
79
83
  raise ReaderFailure('command_timeout') from None
80
84
  if with_status:
85
+ if with_stderr:
86
+ return status, bytes(data), bytes(errors)
81
87
  return status, bytes(data)
82
88
  if status:
83
89
  raise ReaderFailure('command_failed')
@@ -90,6 +96,8 @@ def bounded(argv, *, payload=None, seconds=30, with_status=False):
90
96
  pass
91
97
  process.wait(timeout=5)
92
98
  process.stdout.close()
99
+ if process.stderr:
100
+ process.stderr.close()
93
101
  if process.stdin and not process.stdin.closed:
94
102
  process.stdin.close()
95
103
 
@@ -3,27 +3,50 @@ import argparse
3
3
  import json
4
4
  import re
5
5
  import shlex
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  from identity_diagnostics import ReaderFailure, bounded, retain_result
9
10
  from runtime_projection import RuntimeFailure, envelope, scope, validate
10
11
 
11
12
 
12
- def local(args):
13
+ def transport_signals(status, raw):
14
+ """Observe fixed SSH failure labels; private stderr never enters a receipt."""
15
+ lines = raw.decode('utf-8', errors='replace').splitlines()
16
+ patterns = {
17
+ 1: [('gate_refused', r'backend_ssh_refused')],
18
+ 127: [('remote_command_unavailable',
19
+ r'(?:/bin/)?(?:bash|sh): (?:(?:line )?\d+: )?gowalk-backend: (?:command )?not found')],
20
+ 255: [
21
+ ('authentication_refused', r'[^\r\n]+: Permission denied \([a-z,-]+\)\.'),
22
+ ('host_key_refused', r'Host key verification failed\.'),
23
+ ('connection_refused', r'ssh: connect to host [^\r\n ]+ port \d+: Connection refused'),
24
+ ('connection_timeout', r'ssh: connect to host [^\r\n ]+ port \d+: (?:Connection|Operation) timed out'),
25
+ ('name_resolution_failed', r'ssh: Could not resolve hostname [^\r\n ]+: [^\r\n]+'),
26
+ ],
27
+ }
28
+ return [name for name, pattern in patterns.get(status, [])
29
+ if any(re.fullmatch(pattern, line) for line in lines)]
30
+
31
+
32
+ def local(args, transport=None):
13
33
  scope(args.app, args.service, args.expected_build_sha)
14
34
  if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', args.host or '')
15
35
  or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', args.user)):
16
36
  raise RuntimeFailure('invalid_scope')
17
37
  command = shlex.join(['gowalk-backend', 'runtime-diagnostics', args.app, args.service, args.expected_build_sha])
18
38
  try:
19
- status, raw = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
39
+ status, raw, errors = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
20
40
  '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
21
41
  '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
22
- '-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command], seconds=75, with_status=True)
42
+ '-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command],
43
+ seconds=75, with_status=True, with_stderr=True)
23
44
  except ReaderFailure as error:
24
45
  raise RuntimeFailure(error.code) from None
25
46
  except OSError:
26
47
  raise RuntimeFailure('transport_failure') from None
48
+ if transport is not None:
49
+ transport.update(exit_status=status, signals=transport_signals(status, errors))
27
50
  if status not in (0, 1):
28
51
  raise RuntimeFailure('transport_failure')
29
52
  if status == 1 and not raw.strip():
@@ -45,14 +68,22 @@ def main():
45
68
  parser.add_argument('--host', required=True)
46
69
  parser.add_argument('--user', default='root')
47
70
  parser.add_argument('--output', required=True)
71
+ parser.add_argument('--transport-output', help='Separate closed SSH observation receipt')
48
72
  args = parser.parse_args()
73
+ transport = {'schema': 'gowalk-cicd/backend-runtime-transport.v1', 'exit_status': None, 'signals': []}
49
74
  try:
50
- result = local(args)
75
+ result = local(args, transport)
51
76
  except Exception as error:
52
77
  result = envelope(args.app, args.service, args.expected_build_sha,
53
78
  error.code if isinstance(error, RuntimeFailure) else 'unclassified', reader_version=None)
54
79
  try:
55
80
  retain_result(args.output, result)
81
+ if args.transport_output:
82
+ retain_result(args.transport_output, transport)
83
+ status = 'unknown' if transport['exit_status'] is None else str(transport['exit_status'])
84
+ print('::notice title=backend_runtime_transport::schema=' + transport['schema']
85
+ + ' exit_status=' + status + ' signals=' + (','.join(transport['signals']) or 'none'),
86
+ file=sys.stderr)
56
87
  except ReaderFailure:
57
88
  result = envelope(args.app, args.service, args.expected_build_sha, 'artifact_write_failed', reader_version=None)
58
89
  print(json.dumps(result, separators=(',', ':')))
@@ -5,6 +5,7 @@ import re
5
5
  import subprocess
6
6
 
7
7
  from runtime_healthcheck import bounded_tmpfs, cache_healthcheck
8
+ from runtime_model import escape_interpolation
8
9
 
9
10
 
10
11
  def eligible(service):
@@ -17,7 +18,14 @@ def eligible(service):
17
18
  def image_health(command, name, service):
18
19
  reference = service.get('image')
19
20
  if not reference:
20
- result = subprocess.run(command + ['config', '--images', name],
21
+ resolver = list(command)
22
+ option = next((flag for flag in ('--file', '-f') if flag in resolver), None)
23
+ if option:
24
+ resolver[resolver.index(option) + 1] = '-'
25
+ else:
26
+ resolver.extend(['--file', '-'])
27
+ selected = {'services': {name: {'build': '.'}}}
28
+ result = subprocess.run(resolver + ['config', '--images', name], input=json.dumps(selected),
21
29
  capture_output=True, text=True, timeout=30, check=True)
22
30
  references = result.stdout.split()
23
31
  if len(references) != 1:
@@ -44,6 +52,7 @@ def inherit_healthchecks(model, command):
44
52
  candidate = copy.deepcopy(service)
45
53
  candidate.setdefault('healthcheck', {})['test'] = health['Test']
46
54
  if cache_healthcheck(candidate):
55
+ candidate['healthcheck']['test'] = escape_interpolation(candidate['healthcheck']['test'])
47
56
  service['healthcheck'] = candidate['healthcheck']
48
57
  service.update(image=image['Id'], pull_policy='never')
49
58
  changed = True
@@ -36,6 +36,10 @@ wraps only a missing Compose test, retaining explicit timing and disabled/custom
36
36
  launch model pins the transformed service to that inspected image ID with pulling disabled, so a
37
37
  moving tag cannot substitute another image's healthcheck. Explicit registry-refresh policies and
38
38
  non-build services retain their existing image/pull behavior. No pre-build image healthcheck is reused.
39
+ Imported image commands escape literal dollars once at the Compose boundary, preserving Python
40
+ strings and container-shell expansion without interpreting them against the deployment host environment.
41
+ Default image names resolve from an isolated service model with the same Compose project name;
42
+ dependency images cannot make that selection ambiguous.
39
43
 
40
44
  ## Adopt an existing API
41
45
 
@@ -68,7 +68,7 @@ consumer code or an uploaded script. An older/refusing gate has no reader receip
68
68
  `runtime_reader_unavailable`; locally synthesized failures carry `reader_version: null`, never host-adoption proof.
69
69
  A collector refusal carries its actual `reader_version` and fixed failure code.
70
70
 
71
- The `backend-runtime-diagnostics-<job>-<attempt>` artifact contains only `result.json`, schema
71
+ The `backend-runtime-diagnostics-<job>-<attempt>` artifact contains `result.json`, schema
72
72
  `gowalk-cicd/backend-runtime-diagnostics.v1`. Success binds the expected/observed build to the running API's
73
73
  `BUILD_SHA`, enumerates at most 32 exact-project containers (including stopped ones), and rechecks their exact
74
74
  project/service/working-directory labels. It returns container and image IDs, effective memory/reservation/CPU/PID
@@ -77,6 +77,18 @@ Docker's display precision. CPU samples are Docker's bounded observations, not a
77
77
  collector never launches workload, reads logs, executes in containers or inspects a shared database's cgroup.
78
78
  Both container identity/configuration and the private profile are rechecked before returning success.
79
79
 
80
+ The same artifact also retains `transport.json`, schema `gowalk-cicd/backend-runtime-transport.v1`.
81
+ Its fixed fields also appear in the brace-free `backend_runtime_transport` notice. It reports the SSH child's
82
+ `exit_status` (null when no complete exit was observed) and fixed `signals`: `gate_refused`,
83
+ `remote_command_unavailable`, `authentication_refused`, `host_key_refused`, `connection_refused`,
84
+ `connection_timeout` or `name_resolution_failed`. Unknown text yields no signal; stderr and remote
85
+ command output never enter this receipt. The original `result.json` schema and failure codes remain
86
+ unchanged. These observations explain a refused read, never attest host reader installation or authorize
87
+ a retry or transport bypass. Exit 127 without the exact command-not-found observation does not prove a
88
+ missing reader; a gate refusal does not identify its policy or ownership cause. Normal backend deployment
89
+ does not install the trusted reader, and an updated workflow client alone cannot repair an unavailable
90
+ host command. Install the published host runtime through the procedure below.
91
+
80
92
  `database.mode: host_profile` with `binding_matches_profile: true` proves this app's root-owned profile supplied
81
93
  the API's current private `DATABASE_URL`. `removed_services` describes that profile's applied removals; a remaining
82
94
  removed service refuses. `app_service` means the runtime connection names another reported service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.196",
3
+ "version": "1.0.198",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {