gowalk-cicd 1.0.152 → 1.0.154

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/CLAUDE.md CHANGED
@@ -265,6 +265,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
265
265
  list is unclassified, never a guessed cause; matching signals are observations, not a retry
266
266
  decision. Minute progress notices replace raw compiler output. Preserve the actual exit,
267
267
  command arguments, signing and symbol checks.
268
+ - Offline delivery preflight recognizes compiled single-size icons through `delivery_icons.py`.
269
+ Require matching 1024 `Icon Image` and `MultiSized Image` index records per declared device idiom;
270
+ do not treat an arbitrary 1024 image as coverage or weaken missing-family checks.
268
271
  - iOS plist stamping reads complete JSON build settings through `resolve_info_plist.py`,
269
272
  preserving Xcode's exit status and bounded sanitized stderr. Select exactly the configured
270
273
  bundle's application target and resolve paths from that target's source root; never take
@@ -1 +1 @@
1
- 1.0.152
1
+ 1.0.154
@@ -0,0 +1,33 @@
1
+ """Recognize Xcode's compiled single-size iOS app-icon representation."""
2
+ import re
3
+
4
+
5
+ def single_size_idioms(renditions: list[dict]) -> set[str]:
6
+ """Require a 1024 icon image AND its matching multi-size index for each idiom.
7
+
8
+ Xcode 26.3 expands a universal iOS source slot into phone/pad records. It
9
+ does not retain the source's universal idiom or emit every smaller square.
10
+ A bare large image is insufficient evidence of this compiled icon format.
11
+ """
12
+ images = {
13
+ (row.get("Name"), row.get("Idiom"), row.get("Icon Index"))
14
+ for row in renditions
15
+ if row.get("AssetType") == "Icon Image"
16
+ and row.get("PixelWidth") == 1024 and row.get("PixelHeight") == 1024
17
+ and row.get("Scale") == 1 and row.get("Idiom") in {"phone", "pad"}
18
+ and type(row.get("Icon Index")) is int
19
+ }
20
+ covered = set()
21
+ for row in renditions:
22
+ if row.get("AssetType") != "MultiSized Image" or row.get("Scale") != 1:
23
+ continue
24
+ sizes = row.get("Sizes")
25
+ if not isinstance(sizes, list):
26
+ continue
27
+ for size in sizes:
28
+ match = re.fullmatch(r"1024x1024 index:(\d+) idiom:(phone|pad)", str(size))
29
+ if not match or row.get("Idiom") != match[2]:
30
+ continue
31
+ if (row.get("Name"), match[2], int(match[1])) in images:
32
+ covered.add(match[2])
33
+ return covered
@@ -33,6 +33,8 @@ import subprocess
33
33
  import sys
34
34
  import zipfile
35
35
 
36
+ from delivery_icons import single_size_idioms
37
+
36
38
  SCHEMA = "gowalk-cicd/apple-delivery-preflight.v1"
37
39
 
38
40
  #: Apple's `assetutil` lives outside the selected Xcode and needs no developer dir.
@@ -51,8 +53,9 @@ REQUIRED_ICONS = {
51
53
  #: as a missing icon — that would refuse every correct build.
52
54
  FLATTENED_ICON_SIZES = frozenset({120, 152})
53
55
 
54
- #: Xcode 14+ lets one `universal` 1024 entry stand for every device family, and
55
- #: `actool` then emits a single universal rendition instead of per-idiom ones.
56
+ #: Xcode 14+ lets one `universal` 1024 entry stand for every device family.
57
+ #: Older compiled catalogs can retain a universal rendition. Newer Xcode also
58
+ #: emits indexed phone/pad single-size records, recognized by delivery_icons.
56
59
  #: Demanding `pad` renditions of such a catalog would refuse a correct app, so a
57
60
  #: universal app-icon rendition satisfies every family.
58
61
  UNIVERSAL_IDIOMS = frozenset({"universal"})
@@ -261,10 +264,13 @@ def check_app_icons(ctx: dict) -> list[dict]:
261
264
  # One universal icon covers every family; per-idiom renditions do not exist
262
265
  # for it and demanding them would refuse a correct Xcode 14+ catalog.
263
266
  return []
267
+ single_size = single_size_idioms(_icon_renditions(renditions or []))
264
268
  for family in sorted(families):
265
269
  if family not in REQUIRED_ICONS:
266
270
  continue
267
271
  idiom, required = REQUIRED_ICONS[family]
272
+ if idiom in single_size:
273
+ continue
268
274
  device = "iPad" if family == 2 else "iPhone"
269
275
  for pixels, label in required:
270
276
  if renditions is not None:
@@ -307,7 +313,7 @@ def check_marketing_icon(ctx: dict) -> list[dict]:
307
313
  present = any(item.get("PixelWidth") == 1024 and item.get("PixelHeight") == 1024
308
314
  and str(item.get("Idiom") or "") in UNIVERSAL_IDIOMS | {"marketing"}
309
315
  for item in icons)
310
- if present:
316
+ if present or single_size_idioms(icons):
311
317
  return []
312
318
  return [{"itms": "ITMS-90704",
313
319
  "message": "no 1024x1024 App Store marketing icon in the compiled asset catalog",
@@ -0,0 +1,38 @@
1
+ [
2
+ {
3
+ "AssetType": "Icon Image",
4
+ "Icon Index": 1,
5
+ "Idiom": "phone",
6
+ "Name": "AppIcon",
7
+ "PixelHeight": 1024,
8
+ "PixelWidth": 1024,
9
+ "Scale": 1
10
+ },
11
+ {
12
+ "AssetType": "Icon Image",
13
+ "Icon Index": 1,
14
+ "Idiom": "pad",
15
+ "Name": "AppIcon",
16
+ "PixelHeight": 1024,
17
+ "PixelWidth": 1024,
18
+ "Scale": 1
19
+ },
20
+ {
21
+ "AssetType": "MultiSized Image",
22
+ "Idiom": "phone",
23
+ "Name": "AppIcon",
24
+ "Scale": 1,
25
+ "Sizes": [
26
+ "1024x1024 index:1 idiom:phone"
27
+ ]
28
+ },
29
+ {
30
+ "AssetType": "MultiSized Image",
31
+ "Idiom": "pad",
32
+ "Name": "AppIcon",
33
+ "Scale": 1,
34
+ "Sizes": [
35
+ "1024x1024 index:1 idiom:pad"
36
+ ]
37
+ }
38
+ ]
@@ -0,0 +1,76 @@
1
+ """Regression for Xcode 26.3 compiling one universal iOS 1024 source icon.
2
+
3
+ The fixture retains only structural assetutil fields from a real signed archive;
4
+ it contains neither artwork nor signing data. Catalog mutations must fail closed.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ import unittest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import delivery_preflight as preflight
13
+
14
+
15
+ def context():
16
+ rows = json.loads((Path(__file__).parent / "fixtures/single-size-ios-icon.json").read_text())
17
+ return {"families": {1, 2}, "renditions": rows, "flattened": {120, 152}}
18
+
19
+
20
+ class SingleSizeIconTests(unittest.TestCase):
21
+ def test_real_compiled_single_size_catalog_covers_phone_pad_and_marketing(self):
22
+ ctx = context()
23
+ self.assertEqual(preflight.check_app_icons(ctx), [])
24
+ self.assertEqual(preflight.check_marketing_icon(ctx), [])
25
+
26
+ def test_one_idiom_does_not_cover_the_other_declared_family(self):
27
+ ctx = context()
28
+ ctx["renditions"] = [r for r in ctx["renditions"] if r["Idiom"] == "phone"]
29
+ failures = preflight.check_app_icons(ctx)
30
+ self.assertEqual(len(failures), 2)
31
+ self.assertTrue(all("iPad" in item["message"] for item in failures))
32
+ ctx["families"] = {1}
33
+ self.assertEqual(preflight.check_app_icons(ctx), [])
34
+
35
+ def test_large_image_without_multi_size_index_does_not_prove_coverage(self):
36
+ ctx = context()
37
+ ctx["renditions"] = [r for r in ctx["renditions"] if r["AssetType"] == "Icon Image"]
38
+ self.assertEqual(len(preflight.check_app_icons(ctx)), 4)
39
+ self.assertEqual(len(preflight.check_marketing_icon(ctx)), 1)
40
+
41
+ def test_index_without_icon_image_does_not_prove_coverage(self):
42
+ ctx = context()
43
+ ctx["renditions"] = [r for r in ctx["renditions"] if r["AssetType"] == "MultiSized Image"]
44
+ self.assertEqual(len(preflight.check_app_icons(ctx)), 4)
45
+ self.assertEqual(len(preflight.check_marketing_icon(ctx)), 1)
46
+
47
+ def test_mismatched_catalog_pairs_do_not_cover_the_changed_idiom(self):
48
+ changes = [{"Icon Index": 2}, {"Name": "AppIconOther"}, {"PixelHeight": 512},
49
+ {"PixelWidth": 512}, {"Scale": 2}, {"AssetType": "Image"},
50
+ {"Idiom": "unrelated"}]
51
+ for change in changes:
52
+ with self.subTest(change=change):
53
+ ctx = context()
54
+ ctx["renditions"][0].update(change)
55
+ failures = preflight.check_app_icons(ctx)
56
+ self.assertEqual(len(failures), 2)
57
+ self.assertTrue(all("iPhone" in item["message"] for item in failures))
58
+
59
+ def test_unrelated_asset_name_cannot_supply_an_app_icon(self):
60
+ ctx = context()
61
+ for row in ctx["renditions"]:
62
+ row["Name"] = "HeroImage"
63
+ self.assertEqual(len(preflight.check_app_icons(ctx)), 4)
64
+
65
+ def test_malformed_or_wrong_index_metadata_is_not_coverage(self):
66
+ invalid = [None, "1024x1024 index:1 idiom:phone", ["1024x1024 index:2 idiom:phone"],
67
+ ["1024x1024 index:1 idiom:pad"], ["512x512 index:1 idiom:phone"]]
68
+ for sizes in invalid:
69
+ with self.subTest(sizes=sizes):
70
+ ctx = context()
71
+ ctx["renditions"][2]["Sizes"] = sizes
72
+ self.assertEqual(len(preflight.check_app_icons(ctx)), 2)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.152
1
+ 1.0.154
@@ -1 +1 @@
1
- 1.0.152
1
+ 1.0.154
@@ -204,7 +204,9 @@ def validate_rules(rows):
204
204
  expected += [dict(marked, priority=5210, table='main'), dict(marked, priority=5230, table='default'),
205
205
  dict(marked, priority=5250, action='unreachable'), {'priority': 5270, 'src': 'all', 'table': 52}]
206
206
  actual = []
207
- tables = {255: 'local', 254: 'main', 253: 'default'}
207
+ # iproute2 iprule.c prints table names/numbers as JSON strings, including Tailscale's "52".
208
+ tables = {255: 'local', 254: 'main', 253: 'default',
209
+ '255': 'local', '254': 'main', '253': 'default', '52': 52}
208
210
  for row in rows:
209
211
  if not isinstance(row, dict) or set(row) - {'priority', 'src', 'table', 'protocol',
210
212
  'fwmark', 'fwmask', 'action'}:
@@ -12,6 +12,22 @@ from test_network_inventory import FakeHost, RULES
12
12
 
13
13
 
14
14
  class RuleDiagnosticTests(unittest.TestCase):
15
+ def test_iproute_decimal_table_strings_keep_exact_policy_semantics(self):
16
+ marked = {'src': 'all', 'fwmark': '0x80000', 'fwmask': '0xff0000'}
17
+ rules = [dict(row, table=str({'local': 255, 'main': 254, 'default': 253}[row['table']]))
18
+ for row in RULES]
19
+ validate_rules(rules)
20
+ rules += [dict(marked, priority=5210, table='254'), dict(marked, priority=5230, table='253'),
21
+ dict(marked, priority=5250, action='unreachable'),
22
+ {'priority': 5270, 'src': 'all', 'table': '52'}]
23
+ validate_rules(rules)
24
+ for invalid in ('53', '052', '0x34', 'private table alias', 53, True):
25
+ changed = copy.deepcopy(rules)
26
+ changed[-1]['table'] = invalid
27
+ with self.subTest(table=invalid), self.assertRaises(InventoryFailure) as caught:
28
+ validate_rules(changed)
29
+ self.assertEqual(caught.exception.code, 'host_rules_unsupported_table')
30
+
15
31
  def test_each_rejected_shape_keeps_a_closed_reason_without_values(self):
16
32
  marked = {'src': 'all', 'fwmark': '0x80000', 'fwmask': '0xff0000'}
17
33
  valid = RULES + [dict(marked, priority=5210, table='main'), dict(marked, priority=5230, table='default'),
@@ -82,6 +82,9 @@ Raw container data, labels, environment, daemon configuration and command errors
82
82
  Policy-rule refusals use `host_rules_unsupported_<reason>` to distinguish count, fields, priority,
83
83
  source, table, structure, fwmark, fwmask, action and duplicate mismatches. These fixed classifications
84
84
  retain the strict supported-rule comparison and publish no rejected keys or values.
85
+ The exact decimal-string spellings of supported table IDs are normalized because
86
+ [iproute2 prints rule tables as JSON strings](https://github.com/iproute2/iproute2/blob/v6.8.0/ip/iprule.c#L370).
87
+ This does not admit additional routing tables, aliases, priorities or forwarding policies.
85
88
 
86
89
  The deployment emits its own `gowalk-cicd/backend-networks.v1` receipt. The existing
87
90
  `backend_deploy_failed` phase remains `compose_up` on a preparation refusal. Normal public health,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.152",
3
+ "version": "1.0.154",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {