gowalk-cicd 1.0.207 → 1.0.209

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
@@ -1390,10 +1390,12 @@ Identity failure logs and source Compose files do not establish this applied sta
1390
1390
  ### Read-only bridge and VPN prerequisite diagnostics
1391
1391
 
1392
1392
  Backend Deploy also offers `operation: network-diagnostics`, with an exact `diagnostic-service`, deployed
1393
- `diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` in
1393
+ `diagnostic-build-sha`, owned `diagnostic-network`, proposed namespace-only private IPv4 `/24` or IPv6 ULA `/64` in
1394
1394
  `diagnostic-pool`, exact `diagnostic-vpn-service`, and optional `diagnostic-provider: digitalocean`.
1395
1395
  It reports the owned bridge/IPAM, observed pool conflicts, UDP 500/4500 ownership, bound host identity and
1396
- kernel/firewall facts in a closed v3 artifact. The allocator stays retired; a completed read is never
1396
+ kernel/firewall facts in a closed v3 artifact for IPv4 and v4 for IPv6. The IPv6 result also distinguishes
1397
+ host and owned-gateway default-route and forwarding observations; actual external connectivity stays unverified.
1398
+ The allocator stays retired; a completed read is never
1397
1399
  deployment, port reservation, globally unused address space or tunnel proof. Effective firewall ingress and
1398
1400
  unobserved capabilities remain unverified. The [trusted-host procedure](docs/BACKEND_SSH_GATE.md#owned-bridge-and-vpn-prerequisite-readback)
1399
1401
  defines scope, retained evidence and the required host runtime adoption before this operation is available.
package/action/action.yml CHANGED
@@ -10,6 +10,10 @@ description: >
10
10
  consumers. App Store provisioning profiles are reconciled at runtime.
11
11
 
12
12
  inputs:
13
+ native-preparation-manifest:
14
+ description: Tracked manifest for app-owned native dependency preparation
15
+ required: false
16
+ default: ''
13
17
  candidate-binary:
14
18
  description: Verified candidate artifact directory supplied by the package workflow
15
19
  required: false
@@ -233,6 +237,16 @@ runs:
233
237
  sudo xcode-select -s "$XCODE_PATH"
234
238
  xcodebuild -version
235
239
 
240
+ - name: Prepare manifest-bound native dependencies
241
+ if: ${{ inputs.candidate-binary == '' && inputs.native-preparation-manifest != '' }}
242
+ shell: bash
243
+ env:
244
+ NATIVE_PREPARATION_MANIFEST: ${{ inputs.native-preparation-manifest }}
245
+ run: |
246
+ python3 "$SWIFT_APP_ACTION/scripts/native_preparation.py" prepare \
247
+ "$GITHUB_WORKSPACE" "$NATIVE_PREPARATION_MANIFEST" "$RUNNER_TEMP/native-preparation.json"
248
+ printf 'NATIVE_PREPARATION_MANIFEST=%s\n' "$NATIVE_PREPARATION_MANIFEST" >> "$GITHUB_ENV"
249
+
236
250
  - name: Describe native dependency cache
237
251
  if: ${{ inputs.candidate-binary == '' }}
238
252
  id: native_cache_scope
@@ -95,6 +95,12 @@ def pack(platform: str, directory: Path) -> None:
95
95
  raise ValueError("mobile_artifact_binary_ambiguous")
96
96
  (directory / "export").mkdir()
97
97
  shutil.copyfile(binaries[0], directory / "export" / "app.ipa")
98
+ manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
99
+ if manifest:
100
+ import native_preparation
101
+ receipt = temp / "native-preparation.json"
102
+ native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest, receipt, outputs=True)
103
+ shutil.copyfile(receipt, directory / "native-preparation.json")
98
104
  symbols = ["firebase-symbols", "dart-symbols/ios"]
99
105
  else:
100
106
  binary = Path(os.environ["MOBILE_ARTIFACT_BINARY"])
@@ -120,6 +126,14 @@ def verify(platform: str, directory: Path) -> dict:
120
126
  or value.get("credential_fingerprints") != credentials(platform)
121
127
  or value.get("files") != files(directory)):
122
128
  raise ValueError("mobile_artifact_inputs_mismatch")
129
+ if platform == "ios":
130
+ manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
131
+ if manifest:
132
+ import native_preparation
133
+ native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest,
134
+ directory / "native-preparation.json")
135
+ elif "native-preparation.json" in value["files"]:
136
+ raise ValueError("mobile_artifact_inputs_mismatch")
123
137
  expected = set(IOS_ENV if platform == "ios" else ANDROID_ENV)
124
138
  environment = value.get("environment", {})
125
139
  if set(environment) not in (expected, expected - {SOURCE_PLIST}) or any(
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare and attest tracked, app-authored native build dependencies."""
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path, PurePosixPath
8
+ import re
9
+ import subprocess
10
+ import sys
11
+
12
+ SCHEMA = 'gowalk-cicd/native-preparation.v1'
13
+ RECEIPT = 'gowalk-cicd/native-preparation-receipt.v1'
14
+ MAX_FILES = 10000
15
+
16
+
17
+ def digest(path):
18
+ with path.open('rb') as stream:
19
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
20
+
21
+
22
+ def command(*args, cwd):
23
+ return subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE,
24
+ stderr=subprocess.DEVNULL, text=True).stdout
25
+
26
+
27
+ def path_at(root, name):
28
+ if not isinstance(name, str) or len(name) > 500 or not re.fullmatch(r'[A-Za-z0-9_./-]+', name):
29
+ raise ValueError('native_preparation_path_invalid')
30
+ path = PurePosixPath(name)
31
+ if path.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')):
32
+ raise ValueError('native_preparation_path_invalid')
33
+ if 'creds' in path.parts or '.git' in path.parts or any(part.startswith('.env') for part in path.parts):
34
+ raise ValueError('native_preparation_path_invalid')
35
+ result = root.joinpath(*path.parts)
36
+ current = root
37
+ for part in path.parts:
38
+ current = current / part
39
+ if current.is_symlink():
40
+ raise ValueError('native_preparation_symlink')
41
+ return result
42
+
43
+
44
+ def tracked(root, name):
45
+ path = path_at(root, name)
46
+ if not path.is_file():
47
+ raise ValueError('native_preparation_input_absent')
48
+ command('git', 'ls-files', '--error-unmatch', '--', name, cwd=root)
49
+ command('git', 'diff', '--quiet', 'HEAD', '--', name, cwd=root)
50
+ return digest(path)
51
+
52
+
53
+ def load(root, name):
54
+ manifest_hash = tracked(root, name)
55
+ path = path_at(root, name)
56
+ if path.stat().st_size > 65536:
57
+ raise ValueError('native_preparation_manifest_invalid')
58
+ data = json.loads(path.read_text())
59
+ if (not isinstance(data, dict) or set(data) != {'schema', 'script', 'inputs', 'outputs'}
60
+ or data['schema'] != SCHEMA or not isinstance(data['inputs'], list)
61
+ or not isinstance(data['outputs'], list) or not data['outputs']
62
+ or len(data['inputs']) > 100 or len(data['outputs']) > 100):
63
+ raise ValueError('native_preparation_manifest_invalid')
64
+ inputs = [name, data['script'], *data['inputs']]
65
+ if len(set(inputs)) != len(inputs) or len(set(data['outputs'])) != len(data['outputs']):
66
+ raise ValueError('native_preparation_manifest_invalid')
67
+ hashes = {item: tracked(root, item) for item in inputs}
68
+ if hashes[name] != manifest_hash or not os.access(path_at(root, data['script']), os.X_OK):
69
+ raise ValueError('native_preparation_script_invalid')
70
+ for output in data['outputs']:
71
+ path_at(root, output)
72
+ if command('git', 'ls-files', '-z', '--', output, cwd=root):
73
+ raise ValueError('native_preparation_output_tracked')
74
+ command('git', 'check-ignore', '-q', '--', output, cwd=root)
75
+ return data, hashes
76
+
77
+
78
+ def output_tree(root, name):
79
+ path = path_at(root, name)
80
+ if not path.exists():
81
+ raise ValueError('native_preparation_output_absent')
82
+ paths = [path] if path.is_file() else sorted(path.rglob('*'))
83
+ entries = []
84
+ for item in paths:
85
+ if item.is_symlink():
86
+ raise ValueError('native_preparation_symlink')
87
+ if item.is_file():
88
+ entries.append([item.relative_to(root).as_posix(), digest(item)])
89
+ if len(entries) > MAX_FILES:
90
+ raise ValueError('native_preparation_output_limit')
91
+ elif not item.is_dir():
92
+ raise ValueError('native_preparation_output_invalid')
93
+ if not entries:
94
+ raise ValueError('native_preparation_output_absent')
95
+ return {'path': name, 'files': len(entries), 'sha256': hashlib.sha256(
96
+ json.dumps(entries, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest()}
97
+
98
+
99
+ def identity(root, name):
100
+ data, inputs = load(root, name)
101
+ return data, {'schema': RECEIPT, 'manifest': name, 'inputs': inputs}
102
+
103
+
104
+ def prepare(root, manifest, receipt):
105
+ data, result = identity(root, manifest)
106
+ command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
107
+ allowed = ('PATH', 'HOME', 'TMPDIR', 'RUNNER_TEMP', 'APP_ROBOT_TASK_SCRATCH', 'DEVELOPER_DIR', 'SDKROOT',
108
+ 'CI', 'GITHUB_ACTIONS', 'GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GITHUB_JOB')
109
+ environment = {key: os.environ[key] for key in allowed if key in os.environ}
110
+ if environment.get('GITHUB_ACTIONS') == 'true':
111
+ environment['CI'] = 'true'
112
+ environment['GITHUB_WORKSPACE'] = str(root)
113
+ subprocess.run([str(path_at(root, data['script']))], cwd=root, env=environment, check=True)
114
+ command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
115
+ _, after = identity(root, manifest)
116
+ if result != after:
117
+ raise ValueError('native_preparation_input_changed')
118
+ result['toolchain'] = command('xcodebuild', '-version', cwd=root).strip()
119
+ result['outputs'] = [output_tree(root, name) for name in data['outputs']]
120
+ receipt.write_text(json.dumps(result, sort_keys=True, indent=2) + '\n')
121
+
122
+
123
+ def verify(root, manifest, receipt, *, outputs=False):
124
+ data, expected = identity(root, manifest)
125
+ if receipt.is_symlink() or not receipt.is_file() or receipt.stat().st_size > 1024 * 1024:
126
+ raise ValueError('native_preparation_receipt_invalid')
127
+ actual = json.loads(receipt.read_text())
128
+ if (not isinstance(actual, dict) or set(actual) != set(expected) | {'toolchain', 'outputs'}
129
+ or any(actual.get(k) != v for k, v in expected.items())
130
+ or not isinstance(actual.get('toolchain'), str) or not actual['toolchain']
131
+ or not isinstance(actual.get('outputs'), list) or len(actual['outputs']) != len(data['outputs'])):
132
+ raise ValueError('native_preparation_receipt_invalid')
133
+ for expected_path, item in zip(data['outputs'], actual['outputs']):
134
+ if (not isinstance(item, dict) or set(item) != {'path', 'files', 'sha256'}
135
+ or item['path'] != expected_path or type(item['files']) is not int
136
+ or not 0 < item['files'] <= MAX_FILES or not isinstance(item['sha256'], str)
137
+ or len(item['sha256']) != 64 or any(c not in '0123456789abcdef' for c in item['sha256'])):
138
+ raise ValueError('native_preparation_receipt_invalid')
139
+ if outputs and (actual['outputs'] != [output_tree(root, name) for name in data['outputs']]
140
+ or actual['toolchain'] != command('xcodebuild', '-version', cwd=root).strip()):
141
+ raise ValueError('native_preparation_output_changed')
142
+
143
+
144
+ def main():
145
+ parser = argparse.ArgumentParser()
146
+ parser.add_argument('operation', choices=['prepare', 'verify', 'verify-outputs'])
147
+ parser.add_argument('root', type=Path)
148
+ parser.add_argument('manifest')
149
+ parser.add_argument('receipt', type=Path)
150
+ args = parser.parse_args()
151
+ try:
152
+ root = args.root.resolve(strict=True)
153
+ if args.operation == 'prepare':
154
+ prepare(root, args.manifest, args.receipt)
155
+ else:
156
+ verify(root, args.manifest, args.receipt, outputs=args.operation == 'verify-outputs')
157
+ except (ValueError, OSError, subprocess.SubprocessError, TypeError, KeyError) as error:
158
+ code = str(error) if isinstance(error, ValueError) and str(error).startswith('native_preparation_') else \
159
+ 'native_preparation_failed'
160
+ print(code, file=sys.stderr)
161
+ return 1
162
+ return 0
163
+
164
+
165
+ if __name__ == '__main__':
166
+ sys.exit(main())
@@ -27,7 +27,7 @@ inputs:
27
27
  required: false
28
28
  default: default
29
29
  diagnostic-pool:
30
- description: Proposed strict private IPv4 /24 for a VPN container namespace; never a host allocation.
30
+ description: Proposed strict private IPv4 /24 or IPv6 ULA /64 for a VPN namespace; never a host allocation.
31
31
  required: false
32
32
  default: ""
33
33
  diagnostic-vpn-service:
@@ -105,6 +105,75 @@ def local_docker():
105
105
  raise NetworkFailure('network_scope_mismatch')
106
106
 
107
107
 
108
+ def _ipv6_default(raw):
109
+ if raw is None:
110
+ return 'unverified'
111
+ try:
112
+ rows = json.loads(raw)
113
+ if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
114
+ raise ValueError()
115
+ found = False
116
+ for row in rows:
117
+ if row.get('dst', 'default') not in ('default', '::/0'):
118
+ raise ValueError()
119
+ flags = row.get('flags', [])
120
+ if not isinstance(flags, list) or any(not isinstance(flag, str) for flag in flags):
121
+ raise ValueError()
122
+ kind = row.get('type', 'unicast')
123
+ if kind not in ('unicast', 'blackhole', 'unreachable', 'prohibit', 'throw'):
124
+ raise ValueError()
125
+ device = row.get('dev')
126
+ if kind == 'unicast' and (not isinstance(device, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,15}', device)):
127
+ raise ValueError()
128
+ found |= kind == 'unicast' and device != 'lo' and 'linkdown' not in flags
129
+ return 'present' if found else 'absent'
130
+ except (ValueError, TypeError, UnicodeError):
131
+ return 'unverified'
132
+
133
+
134
+ def ipv6_routes(containers, app, service, bridge, namespace_safe=False):
135
+ host = _ipv6_default(run(['ip', '-j', '-6', 'route', 'show', 'default'], optional=True))
136
+ host_forward = run(['cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
137
+ forwarding = (None if host_forward is None or host_forward.strip() not in (b'0', b'1')
138
+ else host_forward.strip() == b'1')
139
+ matches = [row for row in containers if row['service'] == service and row['running']]
140
+ if len(matches) > 1:
141
+ raise NetworkFailure('network_ambiguous')
142
+ if not matches:
143
+ gateway = 'not_running'
144
+ gateway_forwarding = None
145
+ elif not namespace_safe:
146
+ gateway, gateway_forwarding = 'unverified', None
147
+ else:
148
+ template = ('{"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
149
+ '"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
150
+ '"mode":{{json .HostConfig.NetworkMode}},"key":{{json .NetworkSettings.SandboxKey}}}')
151
+ raw = run(['docker', 'inspect', '--format', template, matches[0]['id']], optional=True)
152
+ try:
153
+ record = json.loads(raw) if raw else {}
154
+ if not isinstance(record, dict):
155
+ raise ValueError()
156
+ if record and (record.get('project') != app or record.get('service') != service
157
+ or record.get('mode') != bridge['name']):
158
+ raise NetworkFailure('network_changed')
159
+ path = record.get('key', '')
160
+ except (ValueError, UnicodeError):
161
+ path = ''
162
+ if not isinstance(path, str):
163
+ path = ''
164
+ if not re.fullmatch(r'/var/run/docker/netns/[0-9a-f]{12,64}', path):
165
+ gateway, gateway_forwarding = 'unverified', None
166
+ else:
167
+ entry = ['nsenter', '--net=' + path, '--']
168
+ raw = run([*entry, 'ip', '-j', '-6', 'route', 'show', 'default'], optional=True)
169
+ gateway = _ipv6_default(raw)
170
+ raw = run([*entry, 'cat', '/proc/sys/net/ipv6/conf/all/forwarding'], optional=True)
171
+ gateway_forwarding = None if raw is None or raw.strip() not in (b'0', b'1') else raw.strip() == b'1'
172
+ return {'host_ipv6_default_route': host, 'gateway_ipv6_default_route': gateway,
173
+ 'host_ipv6_forwarding': forwarding, 'gateway_ipv6_forwarding': gateway_forwarding,
174
+ 'ipv6_external_connectivity': 'unverified'}
175
+
176
+
108
177
  def namespace(containers, app, vpn_service, bridge):
109
178
  info = run(['docker', 'info', '--format', '{"os":{{json .OSType}},"security":{{json .SecurityOptions}}}'])
110
179
  try:
@@ -79,9 +79,11 @@ def selected(rows, app, key):
79
79
  'ipam': [{'subnet': x['Subnet'], 'gateway': x.get('Gateway', '')} for x in row['ipam']]}
80
80
 
81
81
 
82
- def host_networks():
82
+ def host_networks(include_ipv6=False):
83
83
  addresses = decode(run(['ip', '-j', 'address', 'show']))
84
84
  routes = decode(run(['ip', '-j', '-4', 'route', 'show', 'table', 'all']))
85
+ if include_ipv6:
86
+ routes += decode(run(['ip', '-j', '-6', 'route', 'show', 'table', 'all']))
85
87
  result = []
86
88
  try:
87
89
  for row in addresses:
@@ -102,7 +104,7 @@ def host_networks():
102
104
 
103
105
 
104
106
  def overlap(rows, bridge, host_rows, pool):
105
- proposed = ipaddress.IPv4Network(pool)
107
+ proposed = ipaddress.ip_network(pool, strict=True)
106
108
  own = [ipaddress.ip_network(item['subnet']) for item in bridge['ipam']]
107
109
  foreign = [ipaddress.ip_network(item['Subnet']) for row in rows if row['id'] != bridge['network_id']
108
110
  for item in row['ipam'] or []]
@@ -5,6 +5,7 @@ import re
5
5
  from runtime_projection import READER_VERSION, PROJECT, SERVICE, SHA, CID
6
6
 
7
7
  SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v3'
8
+ IPV6_SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v4'
8
9
  FAILURES = frozenset({'invalid_scope', 'network_absent', 'network_ambiguous', 'network_scope_mismatch',
9
10
  'network_changed', 'network_invalid', 'build_mismatch', 'host_binding_mismatch', 'command_timeout',
10
11
  'output_limit', 'command_failed', 'transport_failure', 'remote_invalid', 'artifact_write_failed',
@@ -23,18 +24,22 @@ def scope(app, service, expected, network, pool, vpn_service, host, provider):
23
24
  if any(not isinstance(value, str) or not re.fullmatch(pattern, value) for value, pattern in fields):
24
25
  raise NetworkFailure('invalid_scope')
25
26
  try:
26
- candidate = ipaddress.IPv4Network(pool, strict=True)
27
+ candidate = ipaddress.ip_network(pool, strict=True)
27
28
  except (ValueError, TypeError):
28
29
  raise NetworkFailure('invalid_scope') from None
29
30
  private = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')
30
- if (candidate.prefixlen != 24 or not any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private)
31
- or provider not in ('none', 'digitalocean')):
31
+ v4 = (candidate.version == 4 and candidate.prefixlen == 24
32
+ and any(candidate.subnet_of(ipaddress.IPv4Network(x)) for x in private))
33
+ v6 = (candidate.version == 6 and candidate.prefixlen == 64
34
+ and candidate.subnet_of(ipaddress.IPv6Network('fc00::/7')))
35
+ if not (v4 or v6) or provider not in ('none', 'digitalocean'):
32
36
  raise NetworkFailure('invalid_scope')
33
37
 
34
38
 
35
39
  def envelope(app, service, expected, network, pool, vpn_service, host, provider, failure='', *,
36
40
  reader_version=READER_VERSION):
37
- return {'schema': SCHEMA, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
41
+ schema = IPV6_SCHEMA if isinstance(pool, str) and ':' in pool else SCHEMA
42
+ return {'schema': schema, 'reader_version': reader_version, 'ok': not failure, 'failure': failure,
38
43
  'project': app, 'build_service': service, 'expected_build_sha': expected, 'network_key': network,
39
44
  'proposed_pool': pool, 'vpn_service': vpn_service, 'expected_host': host, 'provider': provider}
40
45
 
@@ -65,7 +70,7 @@ def bridge_record(row, app, network):
65
70
  raise NetworkFailure('remote_invalid') from None
66
71
 
67
72
 
68
- def observation_records(result):
73
+ def observation_records(result, ipv6=False):
69
74
  closed(result['overlap'], ('selected_bridge_foreign_docker', 'selected_bridge_host',
70
75
  'pool_selected_bridge', 'pool_foreign_docker', 'pool_host_routes', 'pool_host_addresses',
71
76
  'default_gateway_destinations'))
@@ -83,8 +88,12 @@ def observation_records(result):
83
88
  if ((row['status'] == 'free') == observed
84
89
  or row['status'] in ('app_vpn', 'foreign') and not row['docker_binding_observed']):
85
90
  raise NetworkFailure('remote_invalid')
86
- closed(result['namespace'], ('docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
87
- 'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'))
91
+ fields = {'docker_linux_rootful', 'host_ipv4_forwarding', 'xfrm_read',
92
+ 'netfilter_nat_read', 'selected_bridge_masquerade', 'vpn_settings'}
93
+ if ipv6:
94
+ fields |= {'host_ipv6_default_route', 'gateway_ipv6_default_route', 'host_ipv6_forwarding',
95
+ 'gateway_ipv6_forwarding', 'ipv6_external_connectivity'}
96
+ closed(result['namespace'], fields)
88
97
  for key in ('docker_linux_rootful', 'host_ipv4_forwarding', 'selected_bridge_masquerade'):
89
98
  if result['namespace'][key] is not None and type(result['namespace'][key]) is not bool:
90
99
  raise NetworkFailure('remote_invalid')
@@ -93,6 +102,14 @@ def observation_records(result):
93
102
  raise NetworkFailure('remote_invalid')
94
103
  if result['namespace']['vpn_settings'] not in ('absent', 'isolated_net_admin', 'other', 'unverified'):
95
104
  raise NetworkFailure('remote_invalid')
105
+ if ipv6:
106
+ row = result['namespace']
107
+ if (row['host_ipv6_default_route'] not in ('present', 'absent', 'unverified')
108
+ or row['gateway_ipv6_default_route'] not in ('present', 'absent', 'not_running', 'unverified')
109
+ or any(row[key] is not None and type(row[key]) is not bool
110
+ for key in ('host_ipv6_forwarding', 'gateway_ipv6_forwarding'))
111
+ or row['ipv6_external_connectivity'] != 'unverified'):
112
+ raise NetworkFailure('remote_invalid')
96
113
 
97
114
 
98
115
  def host_records(result, provider):
@@ -146,6 +163,6 @@ def validate(result, app, service, expected, network, pool, vpn_service, host, p
146
163
  or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', result['observed_at'])):
147
164
  raise NetworkFailure('remote_invalid')
148
165
  bridge_record(result['bridge'], app, network)
149
- observation_records(result)
166
+ observation_records(result, ipv6=':' in pool)
150
167
  host_records(result, provider)
151
168
  return result
@@ -26,23 +26,31 @@ def application(app, service, expected):
26
26
  return rows
27
27
 
28
28
 
29
- def snapshot(app, service, expected):
29
+ def snapshot(app, service, expected, ipv6=False):
30
30
  return {'application': application(app, service, expected), 'networks': network_inventory.networks(),
31
- 'host': network_inventory.host_networks(), 'bindings': network_inventory.port_bindings(),
31
+ 'host': network_inventory.host_networks(include_ipv6=ipv6), 'bindings': network_inventory.port_bindings(),
32
32
  'listeners': network_host.listeners()}
33
33
 
34
34
 
35
35
  def collect(app, service, expected, network, pool, vpn_service, host, provider):
36
36
  scope(app, service, expected, network, pool, vpn_service, host, provider)
37
37
  network_host.local_docker()
38
- original = snapshot(app, service, expected)
38
+ ipv6 = ':' in pool
39
+ original = snapshot(app, service, expected, ipv6=ipv6)
39
40
  bridge = network_inventory.selected(original['networks'], app, network)
40
41
  host_result = network_metadata.observe(host, original['host'], provider)
41
42
  firewall, masquerade, netfilter = network_host.firewall(bridge)
42
43
  namespace = network_host.namespace(original['application'], app, vpn_service, bridge)
43
44
  namespace.update(netfilter_nat_read=netfilter, selected_bridge_masquerade=masquerade)
44
- if snapshot(app, service, expected) != original:
45
+ safe = namespace['vpn_settings'] == 'isolated_net_admin'
46
+ routes = network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
47
+ namespace_safe=safe) if ipv6 else None
48
+ if (snapshot(app, service, expected, ipv6=ipv6) != original
49
+ or ipv6 and network_host.ipv6_routes(original['application'], app, vpn_service, bridge,
50
+ namespace_safe=safe) != routes):
45
51
  raise NetworkFailure('network_changed')
52
+ if routes is not None:
53
+ namespace.update(routes)
46
54
  result = dict(envelope(app, service, expected, network, pool, vpn_service, host, provider),
47
55
  observed_build_sha=expected, bridge=bridge, host=host_result, firewall=firewall, namespace=namespace,
48
56
  overlap=network_inventory.overlap(original['networks'], bridge, original['host'], pool),
@@ -107,7 +107,7 @@ shared-database substitution.
107
107
  `operation: network-diagnostics` is a read-only operation. The network allocator remains retired.
108
108
  Select `diagnostic-service` (the existing API), `diagnostic-network` (the existing Compose bridge key,
109
109
  default `default`), `diagnostic-vpn-service` (default `vpn`), `expected-build-sha`, and `diagnostic-pool`:
110
- a strict RFC1918 IPv4 `/24` proposed only for the VPN container namespace. It creates no address reservation,
110
+ a strict RFC1918 IPv4 `/24` or IPv6 ULA `/64` proposed only for the VPN container namespace. It creates no reservation,
111
111
  network, route, firewall rule, container, certificate or service. Do not use this pool as Compose IPAM.
112
112
  The generated workflow transports `diagnostic-build-sha` separately so a workflow-only commit can inspect
113
113
  the API build actually deployed, rather than claiming its own checkout SHA is running.
@@ -123,7 +123,7 @@ released `network_readback.py`. It accepts no uploaded Python or app-mirror help
123
123
  alone cannot install this host operation: install the integrity-verified published runtime first. A missing
124
124
  host reader returns `network_reader_unavailable` with no host reader version; it is not a network verdict.
125
125
 
126
- The closed `gowalk-cicd/backend-network-diagnostics.v3` result is retained as
126
+ The closed `gowalk-cicd/backend-network-diagnostics.v3` IPv4 result, or `v4` for IPv6, is retained as
127
127
  `backend-network-diagnostics-<job>-<attempt>/result.json`. `ok: true` means the bounded read completed;
128
128
  it never means a VPN is provisioned, traffic is reachable, a pool is globally unused or a tunnel works.
129
129
  The reader binds the existing API's `BUILD_SHA`, checks its exact project/service/directory labels, and
@@ -150,12 +150,17 @@ for client compatibility, so prove adoption with the install receipt and the for
150
150
  only NET_ADMIN plus optional NET_BIND_SERVICE/CHOWN/SETUID/SETGID/SETPCAP for bootstrap and privilege dropping,
151
151
  and namespace-only forwarding/redirect settings. This observes grants, not that a daemon actually dropped privileges.
152
152
  An absent VPN reports `absent`; these observations do not test creating a namespace or applying capabilities.
153
+ - An IPv6 request additionally compares the proposed ULA against host IPv6 routes, addresses and Docker subnets.
154
+ It reports host and owned-gateway IPv6 default-route and forwarding observations independently; missing tools or
155
+ inaccessible namespaces remain `unverified`, and an absent gateway reports `not_running`. A default route is not
156
+ a usable Internet path: `ipv6_external_connectivity` stays `unverified` until an independent traffic exercise.
153
157
  - `firewall` reports observed INPUT/FORWARD policies and explicit UDP ACCEPT rules. Effective ingress
154
158
  remains `unverified`: chained rules, nftables and upstream/provider firewalls need actual external traffic proof.
155
159
 
156
160
  Foreign network ownership/configuration, process identities, raw firewall rules, environment values and
157
161
  private bindings never leave the host. Foreign subnet data is used privately only to calculate overlap flags.
158
- The reader executes no container code, reads no logs and contacts no provider management API.
162
+ The IPv6 reader enters only the verified owned container's Docker network namespace using host `ip` and `cat`; it
163
+ executes no container code, reads no logs and contacts no provider management API.
159
164
  No retry, downgrade, unrestricted SSH or historical inventory can replace a refused current read.
160
165
  Preserve bridge and database identities, assess the observations within their stated scope, then separately
161
166
  verify the actual namespaced gateway configuration, IKEv2 negotiation and encrypted forwarded traffic.
@@ -70,3 +70,45 @@ and pinned cache programs as release builds. Cache Pods and `.factory/toolchain/
70
70
  helper's lockfile/Ruby/Xcode/architecture identity; `pod install --deployment` and committed locks
71
71
  remain required. Beyond that conditional dependency priming, no compile warm-up, machine affinity, reservation
72
72
  or factory wait protocol is added.
73
+
74
+ ## App-owned native preparation
75
+
76
+ A native iOS app whose Xcode graph consumes generated, lockfile-pinned local frameworks may set
77
+ `mobile.ios.native-preparation-manifest` in `.gowalk-cicd.yml` to a tracked JSON file:
78
+
79
+ ```json
80
+ {
81
+ "schema": "gowalk-cicd/native-preparation.v1",
82
+ "script": "tools/ios/prepare-libraries.sh",
83
+ "inputs": ["tools/ios/library.lock.json"],
84
+ "outputs": [".factory/cache/library/ios/Example.xcframework"]
85
+ }
86
+ ```
87
+
88
+ All names are repository-relative. The manifest, executable script and declared inputs must be
89
+ regular tracked files matching the frozen checkout. The tracked tree must remain unchanged through
90
+ preparation. Output trees must be gitignored, contain no tracked files and cannot contain symlinks.
91
+ The script itself must pin and verify its downloaded inputs, must not run tests, and must
92
+ produce all declared outputs with the active Xcode toolchain. The helper executes it as an argument,
93
+ not as an interpolated shell command. App-authored source and this build recipe are subject to the
94
+ same review and trusted-candidate policy as Xcode build phases.
95
+ The child receives a narrow environment, including the nonsecret GitHub run, attempt and job
96
+ identity so a recipe can distinguish a fresh build from same-job cache reuse; credentials and
97
+ unrelated workflow variables are not forwarded.
98
+
99
+ The action runs preparation after selecting Xcode and before Xcode project detection on an actual
100
+ macOS build. It records the source and output hashes and Xcode version, then checks those outputs
101
+ again before packing a candidate. The preparation receipt travels as an authenticated candidate
102
+ artifact file. Promotion verifies its manifest and source hashes along with the binary's existing
103
+ source, credential and artifact checks, and does not rebuild or require generated frameworks on
104
+ Linux. Missing, stale or malformed evidence fails closed. The full tracked mobile source, including
105
+ all scripts, lockfiles and configuration, remains part of the candidate fingerprint; the manifest
106
+ is not a substitute for reviewing a dependency recipe or testing the produced software.
107
+
108
+ Custom validation workflows that need the same graph can invoke the shipped helper before their
109
+ native dependency guard:
110
+
111
+ ```sh
112
+ python3 .github/actions/swift-app/scripts/native_preparation.py prepare \
113
+ "$GITHUB_WORKSPACE" tools/ios/native-deps.json "$RUNNER_TEMP/native-preparation.json"
114
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.207",
3
+ "version": "1.0.209",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,11 @@ function validateNamedSettings(config) {
44
44
  if (candidate !== undefined && typeof candidate !== 'boolean') {
45
45
  throw new MigrationRepair('candidate_setting_requires_boolean', '.gowalk-cicd.yml');
46
46
  }
47
+ const manifest = config.mobile?.ios?.['native-preparation-manifest'];
48
+ if (manifest !== undefined && (typeof manifest !== 'string' || !/^[A-Za-z0-9_.\/-]+$/.test(manifest) ||
49
+ manifest.startsWith('/') || manifest.split('/').some(part => !part || part === '.' || part === '..'))) {
50
+ throw new MigrationRepair('native_preparation_manifest_path_invalid', '.gowalk-cicd.yml');
51
+ }
47
52
  for (const platform of ['ios', 'android']) {
48
53
  const value = config.mobile?.[platform];
49
54
  const allowed = fields.filter(path => path[1] === platform).map(path => path[2]);
@@ -6,7 +6,8 @@ const ios = ['jobs', 'ios', 'steps', 'items', 'id:ios', 'with'];
6
6
  const android = ['jobs', 'android', 'steps', 'items', 'id:android', 'with'];
7
7
  const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'with'];
8
8
  const inputs = {
9
- ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'],
9
+ ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id',
10
+ 'native-preparation-manifest'],
10
11
  android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
11
12
  backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
12
13
  'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
@@ -16,6 +17,8 @@ export function settingsPaths(file) {
16
17
  if (file === 'deploy.yml') return [
17
18
  { name: ['mobile', 'platforms'], path: mobile },
18
19
  ...inputs.ios.map(key => ({ name: ['mobile', 'ios', key], path: [...ios, key] })),
20
+ { name: ['mobile', 'ios', 'native-preparation-manifest'],
21
+ path: ['jobs', 'ios', 'env', 'NATIVE_PREPARATION_MANIFEST'] },
19
22
  ...inputs.android.map(key => ({ name: ['mobile', 'android', key], path: [...android, key] })),
20
23
  ];
21
24
  if (file === 'deploy-backend.yml') return inputs.backend.map(key => ({
@@ -37,7 +37,7 @@ on:
37
37
  required: false
38
38
  diagnostic-pool:
39
39
  type: string
40
- description: Proposed namespace-only VPN private IPv4 /24
40
+ description: Proposed namespace-only VPN private IPv4 /24 or IPv6 ULA /64
41
41
  required: false
42
42
  diagnostic-network:
43
43
  type: string
Binary file