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.
- package/CLAUDE.md +29 -2
- package/README.md +22 -18
- package/action/.daemux-version +1 -1
- package/action/action.yml +48 -38
- package/action/scripts/auto_detect.py +8 -8
- package/action/scripts/commit_bot_changes.sh +3 -29
- package/action/scripts/native_dependency_guard.py +59 -0
- package/action/scripts/native_pods.py +97 -0
- package/action/scripts/native_pods_diagnostics.py +53 -0
- package/action/scripts/native_pods_process.py +93 -0
- package/action/scripts/native_project_graph.py +148 -0
- package/action/scripts/source_maintenance.py +93 -0
- package/action/scripts/source_maintenance_fixture.py +58 -0
- package/action/scripts/test_auto_detect.py +11 -0
- package/action/scripts/test_native_dependency_guard.py +116 -0
- package/action/scripts/test_native_dependency_wiring.py +59 -0
- package/action/scripts/test_native_pods.py +106 -0
- package/action/scripts/test_native_pods_diagnostics.py +39 -0
- package/action/scripts/test_native_pods_process.py +41 -0
- package/action/scripts/test_source_maintenance.py +122 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +3 -1
- package/android-action/play-upload/action.yml +1 -1
- package/android-action/play-upload/owner.cjs +61 -0
- package/android-action/play-upload/owner_http.cjs +63 -0
- package/android-action/play-upload/owner_principal.cjs +15 -0
- package/android-action/play-upload/owner_record.cjs +63 -0
- package/android-action/play-upload/owner_run.cjs +62 -0
- package/android-action/play-upload/run.cjs +14 -3
- package/android-action/scripts/resolve_android.py +19 -1
- package/android-action/scripts/test_early_play_workflow.py +2 -2
- package/android-action/scripts/test_resolve_play_readiness.py +26 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- 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())
|