gowalk-cicd 1.0.78 → 1.0.79

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 (35) hide show
  1. package/CLAUDE.md +29 -2
  2. package/README.md +22 -18
  3. package/action/.daemux-version +1 -1
  4. package/action/action.yml +48 -38
  5. package/action/scripts/auto_detect.py +8 -8
  6. package/action/scripts/commit_bot_changes.sh +3 -29
  7. package/action/scripts/native_dependency_guard.py +59 -0
  8. package/action/scripts/native_pods.py +97 -0
  9. package/action/scripts/native_pods_diagnostics.py +53 -0
  10. package/action/scripts/native_pods_process.py +93 -0
  11. package/action/scripts/native_project_graph.py +148 -0
  12. package/action/scripts/source_maintenance.py +93 -0
  13. package/action/scripts/source_maintenance_fixture.py +58 -0
  14. package/action/scripts/test_auto_detect.py +11 -0
  15. package/action/scripts/test_native_dependency_guard.py +116 -0
  16. package/action/scripts/test_native_dependency_wiring.py +59 -0
  17. package/action/scripts/test_native_pods.py +106 -0
  18. package/action/scripts/test_native_pods_diagnostics.py +39 -0
  19. package/action/scripts/test_native_pods_process.py +41 -0
  20. package/action/scripts/test_source_maintenance.py +122 -0
  21. package/android-action/.daemux-version +1 -1
  22. package/android-action/action.yml +3 -1
  23. package/android-action/play-upload/action.yml +1 -1
  24. package/android-action/play-upload/owner.cjs +61 -0
  25. package/android-action/play-upload/owner_http.cjs +63 -0
  26. package/android-action/play-upload/owner_principal.cjs +15 -0
  27. package/android-action/play-upload/owner_record.cjs +63 -0
  28. package/android-action/play-upload/owner_run.cjs +62 -0
  29. package/android-action/play-upload/run.cjs +14 -3
  30. package/android-action/scripts/resolve_android.py +19 -1
  31. package/android-action/scripts/test_early_play_workflow.py +2 -2
  32. package/android-action/scripts/test_resolve_play_readiness.py +26 -0
  33. package/backend-action/.daemux-version +1 -1
  34. package/package.json +1 -1
  35. package/templates/deploy.yml +8 -3
@@ -0,0 +1,93 @@
1
+ """Drain one retained child session before reaping its leader; never target another process group."""
2
+ import os
3
+ import select
4
+ import signal
5
+ import subprocess
6
+ import time
7
+
8
+
9
+ class Interrupted(RuntimeError):
10
+ pass
11
+
12
+
13
+ class Exit:
14
+ def __init__(self, pid: int):
15
+ self.pid, self.queue, self.done = pid, None, False
16
+ if hasattr(os, "waitid"):
17
+ return
18
+ self.queue = select.kqueue()
19
+ event = select.kevent(pid, filter=select.KQ_FILTER_PROC, flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT,
20
+ fflags=select.KQ_NOTE_EXIT)
21
+ self.queue.control([event], 0, 0)
22
+
23
+ def __call__(self) -> bool:
24
+ if self.queue is None:
25
+ return os.waitid(os.P_PID, self.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None
26
+ if not self.done:
27
+ events = self.queue.control(None, 1, 0)
28
+ if events:
29
+ if events[0].ident != self.pid or not events[0].fflags & select.KQ_NOTE_EXIT:
30
+ raise RuntimeError("native_pods_process_readback_failed")
31
+ self.done = True
32
+ return self.done
33
+
34
+ def close(self):
35
+ if self.queue:
36
+ self.queue.close()
37
+
38
+
39
+ def members(pid: int) -> bool:
40
+ result = subprocess.run(["ps", "-axo", "pid=,pgid=,stat="], capture_output=True, text=True, timeout=2, check=False)
41
+ if result.returncode:
42
+ raise RuntimeError("native_pods_process_readback_failed")
43
+ for line in result.stdout.splitlines():
44
+ fields = line.split()
45
+ if len(fields) != 3 or not fields[0].isdigit() or not fields[1].isdigit():
46
+ raise RuntimeError("native_pods_process_readback_failed")
47
+ if int(fields[1]) == pid and int(fields[0]) != pid and not fields[2].startswith("Z"):
48
+ return True
49
+ return False
50
+
51
+
52
+ def drain(pid: int, exited: Exit) -> None:
53
+ for sig, duration in ((signal.SIGTERM, 3), (signal.SIGKILL, 2)):
54
+ if exited() and not members(pid):
55
+ return
56
+ os.killpg(pid, sig)
57
+ deadline = time.monotonic() + duration
58
+ while time.monotonic() < deadline:
59
+ if exited() and not members(pid):
60
+ return
61
+ time.sleep(0.02)
62
+ raise RuntimeError("native_pods_process_cleanup_unverified")
63
+
64
+
65
+ def command(argv: list[str], cwd, log, *, timeout: float = 900) -> int:
66
+ if not hasattr(os, "waitid") and not hasattr(select, "kqueue"):
67
+ raise RuntimeError("native_pods_process_readback_unsupported")
68
+ child = subprocess.Popen(argv, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
69
+ observer, interrupted = None, False
70
+ try:
71
+ observer = Exit(child.pid)
72
+ deadline = time.monotonic() + timeout
73
+ try:
74
+ while not observer():
75
+ if time.monotonic() >= deadline:
76
+ interrupted = True
77
+ break
78
+ time.sleep(0.02)
79
+ except KeyboardInterrupt:
80
+ interrupted = True
81
+ # WNOWAIT/kqueue retains the PID until the last group member is verified stopped.
82
+ drain(child.pid, observer)
83
+ except BaseException:
84
+ # Retained PID still owns this group even when readback itself failed.
85
+ os.killpg(child.pid, signal.SIGKILL)
86
+ raise
87
+ finally:
88
+ if observer:
89
+ observer.close()
90
+ code = child.wait(timeout=5)
91
+ if interrupted:
92
+ raise Interrupted("native_pods_operation_interrupted")
93
+ return code
@@ -0,0 +1,148 @@
1
+ """Bounded local Xcode graph traversal; no Xcode process or provider requests."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ import plistlib
7
+ import subprocess
8
+ import xml.etree.ElementTree as ET
9
+
10
+ PACKAGES = {"XCRemoteSwiftPackageReference", "XCLocalSwiftPackageReference", "XCSwiftPackageProductDependency"}
11
+
12
+
13
+ class Refused(ValueError):
14
+ """Only fixed codes cross the guard's public boundary."""
15
+
16
+
17
+ def owned(root: Path, path: Path, *, missing: bool = False) -> Path:
18
+ result = path.resolve(strict=not missing)
19
+ if not result.is_relative_to(root):
20
+ raise Refused("native_workspace_reference_unverified")
21
+ return result
22
+
23
+
24
+ def selected(root: Path, container: str, before_pods: bool) -> Path:
25
+ if not container:
26
+ choices = sorted(root.glob("*.xcworkspace")) or sorted(root.glob("*.xcodeproj"))
27
+ if len(choices) != 1:
28
+ raise Refused("native_project_selection_required")
29
+ return owned(root, choices[0])
30
+ path = owned(root, root / container, missing=before_pods)
31
+ if not path.exists() and before_pods and path.suffix == ".xcworkspace":
32
+ path = path.with_suffix(".xcodeproj")
33
+ return owned(root, path)
34
+
35
+
36
+ def document(path: Path, maximum: int) -> bytes:
37
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > maximum:
38
+ raise Refused("native_project_parse_unverified")
39
+ return path.read_bytes()
40
+
41
+
42
+ def workspace(root: Path, path: Path, before_pods: bool, initial_pods_lock: bool) -> list[Path]:
43
+ try:
44
+ tree = ET.fromstring(document(path / "contents.xcworkspacedata", 1048576))
45
+ except ET.ParseError:
46
+ raise Refused("native_workspace_reference_unverified") from None
47
+ pending, found, count = [(tree, path.parent, 0)], [], 0
48
+ while pending:
49
+ node, parent, depth = pending.pop()
50
+ count += 1
51
+ if count > 10000 or depth > 20:
52
+ raise Refused("native_workspace_reference_unverified")
53
+ kind, sep, value = node.get("location", "group:").partition(":")
54
+ if not sep or kind not in {"group", "container", "self", "absolute"}:
55
+ raise Refused("native_workspace_reference_unverified")
56
+ target = owned(root, (parent if kind == "group" else path.parent) / value, missing=True)
57
+ if node.tag == "FileRef" and (target.name == "Package.swift" or (target / "Package.swift").is_file()):
58
+ raise Refused("native_package_proxy_transport_required")
59
+ if node.tag == "FileRef" and target.suffix in {".xcodeproj", ".xcworkspace"}:
60
+ pods = path.parent / "Pods/Pods.xcodeproj"
61
+ deferred = (before_pods and target == pods and not target.exists()
62
+ and (path.parent / "Podfile").is_file()
63
+ and (initial_pods_lock or (path.parent / "Podfile.lock").is_file()))
64
+ if not deferred:
65
+ found.append(owned(root, target))
66
+ pending.extend((child, target, depth + 1) for child in node)
67
+ return found + schemes(root, path)
68
+
69
+
70
+ def objects(path: Path) -> dict:
71
+ body = document(path / "project.pbxproj", 16 * 1024 * 1024)
72
+ try:
73
+ value = plistlib.loads(body)
74
+ except (ValueError, plistlib.InvalidFileException):
75
+ try:
76
+ result = subprocess.run(["plutil", "-convert", "json", "-o", "-", "--", str(path / "project.pbxproj")],
77
+ capture_output=True, timeout=15, check=False)
78
+ value = json.loads(result.stdout) if result.returncode == 0 else None
79
+ except (OSError, ValueError, subprocess.SubprocessError):
80
+ value = None
81
+ rows = value.get("objects") if isinstance(value, dict) else None
82
+ if (not isinstance(rows, dict) or not rows or len(rows) > 100000
83
+ or not all(isinstance(r, dict) for r in rows.values())):
84
+ raise Refused("native_project_parse_unverified")
85
+ return rows
86
+
87
+
88
+ def reference(root: Path, path: Path, rows: dict, key: str, parents: dict, chain: tuple = ()) -> Path:
89
+ if key in chain or len(chain) > 20 or key not in rows:
90
+ raise Refused("native_workspace_reference_unverified")
91
+ row = rows[key]
92
+ tree, value = row.get("sourceTree", "<group>"), row.get("path", "")
93
+ if not isinstance(value, str) or "$" in value:
94
+ raise Refused("native_workspace_reference_unverified")
95
+ if tree in {"SOURCE_ROOT", "<absolute>"}:
96
+ base = path.parent
97
+ elif tree == "<group>":
98
+ candidates = parents.get(key, [])
99
+ if len(candidates) > 1:
100
+ raise Refused("native_workspace_reference_unverified")
101
+ base = reference(root, path, rows, candidates[0], parents, (*chain, key)) if candidates else path.parent
102
+ else:
103
+ raise Refused("native_workspace_reference_unverified")
104
+ return owned(root, base / value, missing=True)
105
+
106
+
107
+ def project(root: Path, path: Path) -> list[Path]:
108
+ rows, parents, found = objects(path), {}, []
109
+ for key, row in rows.items():
110
+ if not isinstance(row.get("isa"), str):
111
+ raise Refused("native_project_parse_unverified")
112
+ if row.get("isa") in PACKAGES or row.get("packageReferences") or row.get("packageProductDependencies"):
113
+ raise Refused("native_package_proxy_transport_required")
114
+ if row.get("isa") == "PBXProject" and any(row.get(key, "") not in ("", ".")
115
+ for key in ("projectDirPath", "projectRoot")):
116
+ raise Refused("native_workspace_reference_unverified")
117
+ children = row.get("children", [])
118
+ if not isinstance(children, list) or not all(isinstance(child, str) for child in children):
119
+ raise Refused("native_project_parse_unverified")
120
+ for child in children:
121
+ parents.setdefault(child, []).append(key)
122
+ for key, row in rows.items():
123
+ is_project = (str(row.get("path", "")).endswith(".xcodeproj")
124
+ or row.get("lastKnownFileType") == "wrapper.pb-project")
125
+ if row.get("isa") == "PBXFileReference" and is_project:
126
+ found.append(owned(root, reference(root, path, rows, key, parents)))
127
+ embedded = path / "project.xcworkspace"
128
+ if embedded.exists():
129
+ found.append(owned(root, embedded))
130
+ return found + schemes(root, path)
131
+
132
+
133
+ def schemes(root: Path, container: Path) -> list[Path]:
134
+ paths = list(container.glob("xcshareddata/xcschemes/*.xcscheme"))
135
+ if len(paths) > 200:
136
+ raise Refused("native_workspace_reference_unverified")
137
+ found = []
138
+ for path in paths:
139
+ try:
140
+ tree = ET.fromstring(document(owned(root, path), 1048576))
141
+ except ET.ParseError:
142
+ raise Refused("native_workspace_reference_unverified") from None
143
+ for node in tree.iter("BuildableReference"):
144
+ kind, sep, value = node.get("ReferencedContainer", "").partition(":")
145
+ if kind != "container" or not sep:
146
+ raise Refused("native_workspace_reference_unverified")
147
+ found.append(owned(root, container.parent / value))
148
+ return found
@@ -0,0 +1,93 @@
1
+ """Preserve staged CI source for the app session without mutating its build checkout."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def git(*args: str, data: str | None = None, allowed: tuple[int, ...] = (0,)) -> str:
12
+ result = subprocess.run(["git", *args], input=data, text=True, capture_output=True, check=False)
13
+ if result.returncode not in allowed:
14
+ raise ValueError("source_maintenance_git_failed")
15
+ return result.stdout.strip()
16
+
17
+
18
+ def source_paths() -> list[str]:
19
+ names = git("diff", "--cached", "--name-only", "-z").split("\0")
20
+ names = [name for name in names if name]
21
+ for name in names:
22
+ if (name.startswith("creds/") or "\n" in name or "\r" in name
23
+ or re.search(r"\.(?:p8|p12|pfx|pem|key|keystore|jks|mobileprovision)$", name, re.I)):
24
+ raise ValueError("source_maintenance_secret_path_refused")
25
+ managed = name.startswith((".github/actions/swift-app/", ".github/actions/android-app/"))
26
+ version = re.search(r"(?:\.pbxproj|\.plist|(?:^|/)[Pp]roject\.ya?ml|\.xcconfig)$", name)
27
+ if not (managed or version):
28
+ raise ValueError("source_maintenance_unexpected_path")
29
+ return names
30
+
31
+
32
+ def remote_commit(branch: str) -> str:
33
+ rows = git("ls-remote", "--heads", "origin", f"refs/heads/{branch}").splitlines()
34
+ if not rows:
35
+ return ""
36
+ if len(rows) != 1 or not re.fullmatch(r"[0-9a-f]{40}\trefs/heads/" + re.escape(branch), rows[0]):
37
+ raise ValueError("source_maintenance_remote_identity_invalid")
38
+ return rows[0].split()[0]
39
+
40
+
41
+ def validate_origin(repo: str, base: str) -> None:
42
+ origin = git("config", "--get", "remote.origin.url")
43
+ match = re.fullmatch(r"(?:https://github\.com/|git@github\.com:)([\w.-]+/[\w.-]+?)(?:\.git)?", origin)
44
+ if not match or match[1].casefold() != repo.casefold():
45
+ raise ValueError("source_maintenance_repository_mismatch")
46
+ if os.environ.get("GITHUB_SHA", base) != base:
47
+ raise ValueError("source_maintenance_build_head_changed")
48
+
49
+
50
+ def preserve(base: str, tree: str, branch: str, subject: str) -> str:
51
+ head = remote_commit(branch)
52
+ if not head:
53
+ head = git("commit-tree", tree, "-p", base, data=subject.replace(" [skip ci]", "") + "\n")
54
+ # A transport refusal is ambiguous: settle by exact remote readback, never force or rebase.
55
+ git("push", "origin", f"{head}:refs/heads/{branch}", allowed=(0, 1, 128))
56
+ remote = remote_commit(branch)
57
+ if not remote:
58
+ raise ValueError("source_maintenance_push_unconfirmed")
59
+ head = remote
60
+ git("fetch", "--no-tags", "origin", f"refs/heads/{branch}")
61
+ if git("rev-parse", "FETCH_HEAD") != head:
62
+ raise ValueError("source_maintenance_remote_changed")
63
+ if git("show", "-s", "--format=%P %T", head) != f"{base} {tree}":
64
+ raise ValueError("source_maintenance_candidate_mismatch")
65
+ return head
66
+
67
+
68
+ def main() -> int:
69
+ names = source_paths()
70
+ if not names:
71
+ print("source maintenance: nothing staged")
72
+ return 0
73
+ repo, run_id = os.environ.get("GITHUB_REPOSITORY", ""), os.environ.get("GITHUB_RUN_ID", "")
74
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not run_id.isdecimal():
75
+ raise ValueError("source_maintenance_run_identity_required")
76
+ base, tree = git("rev-parse", "HEAD"), git("write-tree")
77
+ validate_origin(repo, base)
78
+ branch = f"task/ci-maintenance-{run_id}-{tree[:16]}"
79
+ head = preserve(base, tree, branch, sys.argv[1] if len(sys.argv) > 1 else "Preserve CI source maintenance")
80
+ receipt = {"schema": "gowalk-cicd/source-maintenance-pending.v1", "repo": repo,
81
+ "base_sha": base, "head_sha": head, "branch": branch, "run_id": run_id}
82
+ # Clear only the preserved index entries. Worktree bytes and HEAD stay exactly as the build expects.
83
+ git("reset", "--quiet", "HEAD", "--", *names)
84
+ print("::notice title=source_maintenance_pending::" + json.dumps(receipt, separators=(",", ":")))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ try:
90
+ raise SystemExit(main())
91
+ except ValueError as exc:
92
+ print("::error::" + str(exc), file=sys.stderr)
93
+ raise SystemExit(1) from None
@@ -0,0 +1,58 @@
1
+ """Exercise maintenance delivery against an actual protected bare Git remote."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+
10
+ SCRIPT = Path(os.environ.get("MAINTENANCE_SCRIPT", Path(__file__).with_name("commit_bot_changes.sh")))
11
+
12
+
13
+ class MaintenanceFixture(unittest.TestCase):
14
+ def setUp(self):
15
+ self.temp = tempfile.TemporaryDirectory(prefix="source-maintenance-")
16
+ self.addCleanup(self.temp.cleanup)
17
+ self.root = Path(self.temp.name)
18
+ self.repo, self.remote = self.root / "repo", self.root / "remote.git"
19
+ self.repo.mkdir()
20
+ self.env = {**os.environ, "GITHUB_REPOSITORY": "example/app", "GITHUB_RUN_ID": "1234",
21
+ "GITHUB_REF_NAME": "main", "GIT_TERMINAL_PROMPT": "0"}
22
+ self.git("init", "--bare", str(self.remote))
23
+ self.git("init", "-b", "main")
24
+ self.git("config", "user.name", "Fixture")
25
+ self.git("config", "user.email", "fixture@example.test")
26
+ (self.repo / "project.yml").write_text("version: 1.0\n")
27
+ (self.repo / "unrelated.txt").write_text("original\n")
28
+ self.git("add", ".")
29
+ self.git("commit", "-m", "Initial fixture")
30
+ self.git("remote", "add", "origin", "https://github.com/example/app.git")
31
+ self.git("config", f"url.{self.remote}.insteadOf", "https://github.com/example/app.git")
32
+ self.git("push", "-u", "origin", "main")
33
+ self.base = self.git("rev-parse", "HEAD").strip()
34
+ self.env["GITHUB_SHA"] = self.base
35
+ self.hook = self.remote / "hooks/pre-receive"
36
+ self.hook.write_text("#!/bin/sh\nwhile read old new ref; do\n"
37
+ " [ \"$ref\" != refs/heads/main ] || exit 1\ndone\n")
38
+ self.hook.chmod(0o755)
39
+
40
+ def git(self, *args):
41
+ return subprocess.check_output(["git", *args], cwd=self.repo, env=self.env,
42
+ stderr=subprocess.DEVNULL, text=True)
43
+
44
+ def stage(self):
45
+ (self.repo / "project.yml").write_text("version: 1.1\n")
46
+ self.git("add", "project.yml")
47
+ (self.repo / "unrelated.txt").write_text("unfinished build change\n")
48
+
49
+ def run_script(self):
50
+ return subprocess.run(["bash", str(getattr(self, "script", SCRIPT))], cwd=self.repo, env=self.env,
51
+ text=True, capture_output=True, timeout=15)
52
+
53
+ def receipt(self, result):
54
+ self.assertEqual(result.returncode, 0, result.stderr)
55
+ lines = [line for line in result.stdout.splitlines()
56
+ if line.startswith("::notice title=source_maintenance_pending::")]
57
+ self.assertEqual(len(lines), 1, result.stdout + result.stderr)
58
+ return json.loads(lines[0].split("::", 2)[2])
@@ -22,6 +22,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
22
  import auto_detect # noqa: E402
23
23
 
24
24
 
25
+ # These discovery tests fake Xcode output; the graph guard has separate actual project/CLI tests.
26
+ def setUpModule():
27
+ global _graph_guard
28
+ _graph_guard = mock.patch.object(auto_detect.native_dependency_guard, "check", return_value={"ok": True})
29
+ _graph_guard.start()
30
+
31
+
32
+ def tearDownModule():
33
+ _graph_guard.stop()
34
+
35
+
25
36
  APP_TYPE = "com.apple.product-type.application"
26
37
  EXT_TYPE = "com.apple.product-type.app-extension"
27
38
  TUN_TYPE = "com.apple.product-type.tunnel-provider"
@@ -0,0 +1,116 @@
1
+ """Actual local graph boundaries precede any mocked Xcode command."""
2
+ import json
3
+ from pathlib import Path
4
+ import plistlib
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import auto_detect
12
+ import native_dependency_guard as guard
13
+
14
+
15
+ def project(root, name="App.xcodeproj", extra=None):
16
+ path = root / name
17
+ path.mkdir(parents=True, exist_ok=True)
18
+ rows = {"root": {"isa": "PBXProject", "mainGroup": "group"},
19
+ "group": {"isa": "PBXGroup", "children": [], "sourceTree": "<group>"}}
20
+ rows.update(extra or {})
21
+ (path / "project.pbxproj").write_bytes(plistlib.dumps({"objects": rows}))
22
+ return path
23
+
24
+
25
+ class GuardTests(unittest.TestCase):
26
+ def setUp(self):
27
+ self.temp = tempfile.TemporaryDirectory(prefix="app-robot-native-guard-")
28
+ self.addCleanup(self.temp.cleanup)
29
+ self.root = Path(self.temp.name).resolve()
30
+ auto_detect._clear_caches()
31
+
32
+ def test_selected_local_source_ignores_unselected_remote_sample(self):
33
+ project(self.root)
34
+ project(self.root, "sample/Remote.xcodeproj", {"bad": {"isa": "XCRemoteSwiftPackageReference"}})
35
+ self.assertEqual(guard.check(self.root, "App.xcodeproj")["projects"], 1)
36
+
37
+ def test_nested_group_dependency_is_checked(self):
38
+ rows = {"group": {"isa": "PBXGroup", "children": ["nested"]},
39
+ "nested": {"isa": "PBXGroup", "path": "vendor", "children": ["ref"]},
40
+ "ref": {"isa": "PBXFileReference", "path": "Child.xcodeproj", "sourceTree": "<group>"}}
41
+ project(self.root, extra=rows)
42
+ project(self.root, "vendor/Child.xcodeproj")
43
+ self.assertEqual(guard.check(self.root, "App.xcodeproj")["projects"], 2)
44
+ project(self.root, "vendor/Child.xcodeproj", {"bad": {"isa": "XCLocalSwiftPackageReference"}})
45
+ with self.assertRaisesRegex(guard.Refused, "native_package_proxy_transport_required"):
46
+ guard.check(self.root, "App.xcodeproj")
47
+
48
+ def test_workspace_pods_deferral_is_exact_and_preinstallation_only(self):
49
+ project(self.root)
50
+ workspace = self.root / "App.xcworkspace"
51
+ workspace.mkdir()
52
+ xml = ('<Workspace><FileRef location="group:App.xcodeproj"/>'
53
+ '<FileRef location="group:Pods/Pods.xcodeproj"/></Workspace>')
54
+ (workspace / "contents.xcworkspacedata").write_text(xml)
55
+ (self.root / "Podfile").write_text("fixture")
56
+ with self.assertRaises(OSError):
57
+ guard.check(self.root, "App.xcworkspace", before_pods=True)
58
+ self.assertEqual(guard.check(self.root, "App.xcworkspace", before_pods=True,
59
+ initial_pods_lock=True)["projects"], 1)
60
+ with self.assertRaisesRegex(guard.Refused, "native_initial_lock_phase_required"):
61
+ guard.check(self.root, "App.xcworkspace", initial_pods_lock=True)
62
+ (self.root / "Podfile.lock").write_text("fixture")
63
+ self.assertEqual(guard.check(self.root, "App.xcworkspace", before_pods=True)["phase"], "before_pods")
64
+ with self.assertRaises(OSError):
65
+ guard.check(self.root, "App.xcworkspace")
66
+ project(self.root, "Pods/Pods.xcodeproj")
67
+ self.assertEqual(guard.check(self.root, "App.xcworkspace")["projects"], 2)
68
+
69
+ def test_scheme_cannot_import_an_unexamined_project(self):
70
+ path = project(self.root)
71
+ project(self.root, "Other.xcodeproj", {"bad": {"isa": "XCRemoteSwiftPackageReference"}})
72
+ schemes = path / "xcshareddata/xcschemes"
73
+ schemes.mkdir(parents=True)
74
+ (schemes / "App.xcscheme").write_text(
75
+ '<Scheme><BuildableReference ReferencedContainer="container:Other.xcodeproj"/></Scheme>')
76
+ with self.assertRaisesRegex(guard.Refused, "native_package_proxy_transport_required"):
77
+ guard.check(self.root, "App.xcodeproj")
78
+
79
+ def test_outside_symlink_and_ambiguous_selection_refuse(self):
80
+ project(self.root)
81
+ project(self.root, "Other.xcodeproj")
82
+ with self.assertRaisesRegex(guard.Refused, "native_project_selection_required"):
83
+ guard.check(self.root)
84
+ (self.root / "Outside.xcodeproj").symlink_to(self.root.parent)
85
+ with self.assertRaisesRegex(guard.Refused, "native_workspace_reference_unverified"):
86
+ guard.check(self.root, "Outside.xcodeproj")
87
+
88
+ def test_remote_graph_refuses_each_actual_xcode_entry_before_subprocess(self):
89
+ project(self.root, extra={"bad": {"isa": "XCRemoteSwiftPackageReference",
90
+ "repositoryURL": "https://secret.invalid"}})
91
+ calls = [lambda: auto_detect._list_schemes(self.root, "App.xcodeproj", ""),
92
+ lambda: auto_detect._show_build_settings(self.root, "App.xcodeproj", "", "App", "Debug"),
93
+ lambda: auto_detect._resolve_package_dependencies(self.root, "App.xcodeproj", "")]
94
+ with patch.object(auto_detect.subprocess, "run") as run:
95
+ for call in calls:
96
+ with self.assertRaisesRegex(guard.Refused, "native_package_proxy_transport_required"):
97
+ call()
98
+ run.assert_not_called()
99
+ result = subprocess.run([sys.executable, guard.__file__, str(self.root), "--container", "App.xcodeproj"],
100
+ capture_output=True, text=True)
101
+ self.assertEqual(result.returncode, 1)
102
+ self.assertNotIn("secret.invalid", result.stdout + result.stderr)
103
+ self.assertIn("CocoaPods", json.loads(result.stdout)["next"])
104
+
105
+ def test_workspace_package_folder_is_not_treated_as_an_ordinary_document(self):
106
+ project(self.root)
107
+ package = self.root / "LocalPackage"
108
+ package.mkdir()
109
+ (package / "Package.swift").write_text("// owns a potentially remote SwiftPM graph")
110
+ workspace = self.root / "App.xcworkspace"
111
+ workspace.mkdir()
112
+ (workspace / "contents.xcworkspacedata").write_text(
113
+ '<Workspace><FileRef location="group:App.xcodeproj"/>'
114
+ '<FileRef location="group:LocalPackage"/></Workspace>')
115
+ with self.assertRaisesRegex(guard.Refused, "native_package_proxy_transport_required"):
116
+ guard.check(self.root, "App.xcworkspace")
@@ -0,0 +1,59 @@
1
+ """Validate the actual action guards and immutable transport snapshot boundaries."""
2
+ from pathlib import Path
3
+ import unittest
4
+
5
+ from test_action_step_order import step_block, step_names
6
+
7
+ ACTION = Path(__file__).resolve().parents[1] / "action.yml"
8
+
9
+
10
+ class WiringTests(unittest.TestCase):
11
+ def test_every_selected_xcode_step_has_its_own_preflight(self):
12
+ source = ACTION.read_text()
13
+ names = {name: step_block(source, name) for name in step_names(source)}
14
+ for name, operation in (("Run simulator tests", "xcodebuild "),
15
+ ("Resolve marketing version (project source of truth)", "resolve_marketing_version.py"),
16
+ ("Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)",
17
+ "resolve_info_plist.py"), ("Archive", "xcodebuild ")):
18
+ body = names[name]
19
+ self.assertLess(body.index("native_dependency_guard.py"), body.index(operation))
20
+ self.assertIn('--container "${WORKSPACE:-$PROJECT}"', body)
21
+ resolve = names["Resolve credentials + auto-detect Xcode project"]
22
+ read_config = 'python3 "$SWIFT_APP_ACTION/scripts/read_config.py"'
23
+ self.assertLess(resolve.index("native_pods.py"), resolve.index(read_config))
24
+ self.assertIn('export INPUT_WORKSPACE', resolve)
25
+ snapshot = names["Snapshot this action for the whole run"]
26
+ self.assertIn("play_store_proxy.py jvm_proxy_relay.py", snapshot)
27
+ self.assertIn("swift-app-action/google-transport", snapshot)
28
+
29
+ def test_shell_blocks_parse_without_exec(self):
30
+ import subprocess
31
+ import textwrap
32
+ source = ACTION.read_text()
33
+ for name in step_names(source):
34
+ body = step_block(source, name)
35
+ if "native_dependency_guard.py" in body or "native_pods.py" in body:
36
+ script = textwrap.dedent(body.split(" run: |\n", 1)[1])
37
+ result = subprocess.run(["bash", "-n"], input=script, text=True, capture_output=True)
38
+ self.assertEqual(result.returncode, 0, name)
39
+
40
+ def test_real_archive_shell_refuses_remote_graph_before_xcode(self):
41
+ import os
42
+ import subprocess
43
+ import tempfile
44
+ import textwrap
45
+ from test_native_dependency_guard import project
46
+ with tempfile.TemporaryDirectory(prefix="app-robot-native-archive-") as temporary:
47
+ root = Path(temporary).resolve()
48
+ project(root, extra={"remote": {"isa": "XCRemoteSwiftPackageReference"}})
49
+ xcode = root / "xcodebuild"
50
+ xcode.write_text('#!/bin/sh\ntouch "$GITHUB_WORKSPACE/xcode-called"\n')
51
+ xcode.chmod(0o700)
52
+ script = textwrap.dedent(step_block(ACTION.read_text(), "Archive").split(" run: |\n", 1)[1])
53
+ env = {**os.environ, "GITHUB_WORKSPACE": str(root), "SWIFT_APP_ACTION": str(ACTION.parent),
54
+ "PROJECT": "App.xcodeproj", "WORKSPACE": "", "PATH": str(root) + os.pathsep + os.environ["PATH"]}
55
+ result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script],
56
+ cwd=root, env=env, capture_output=True, text=True, timeout=10)
57
+ self.assertEqual(result.returncode, 1)
58
+ self.assertIn("native_package_proxy_transport_required", result.stdout)
59
+ self.assertFalse((root / "xcode-called").exists())