unity-devkit 0.1.8__tar.gz → 0.1.10__tar.gz

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 (29) hide show
  1. unity_devkit-0.1.10/.github/workflows/ci.yml +26 -0
  2. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/.github/workflows/release.yml +13 -1
  3. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/AGENTS.md +2 -2
  4. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/PKG-INFO +1 -1
  5. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/pyproject.toml +1 -1
  6. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/install.py +75 -72
  7. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/license.py +2 -6
  8. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/unity.py +77 -86
  9. unity_devkit-0.1.8/.github/workflows/ci.yml +0 -15
  10. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/.gitattributes +0 -0
  11. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/.github/workflows/unity-build.yml +0 -0
  12. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/.gitignore +0 -0
  13. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/CLAUDE.md +0 -0
  14. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/LICENSE +0 -0
  15. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/NOTICE +0 -0
  16. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/README.md +0 -0
  17. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/publish-config.json +0 -0
  18. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/ruff.base.toml +0 -0
  19. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/ruff.toml +0 -0
  20. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/__init__.py +0 -0
  21. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/build_unity.py +0 -0
  22. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/compile_unity.py +9 -9
  23. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/license_restore.py +10 -10
  24. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/lock_unity.py +0 -0
  25. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/matrix.py +0 -0
  26. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/projects.py +0 -0
  27. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/py.typed +0 -0
  28. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/src/unity_devkit/test_unity.py +0 -0
  29. {unity_devkit-0.1.8 → unity_devkit-0.1.10}/uv.lock +0 -0
@@ -0,0 +1,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ check:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ steps:
19
+ - uses: actions/checkout@v5
20
+
21
+ - uses: astral-sh/setup-uv@v7
22
+ with:
23
+ enable-cache: true
24
+
25
+ - name: Preflight
26
+ run: uvx --from python-devkit==0.1.5 preflight-python
@@ -6,6 +6,10 @@ on:
6
6
  types: [completed]
7
7
  branches: [main]
8
8
 
9
+ concurrency:
10
+ group: release
11
+ cancel-in-progress: false
12
+
9
13
  jobs:
10
14
  publish:
11
15
  if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'
@@ -15,5 +19,13 @@ jobs:
15
19
  id-token: write
16
20
  steps:
17
21
  - uses: actions/checkout@v5
22
+ with:
23
+ ref: ${{ github.event.workflow_run.head_sha }}
24
+
25
+ # Workaround: fetch-tags is broken with shallow clones
26
+ - run: git fetch --tags origin
27
+
28
+ - uses: astral-sh/setup-uv@v7
18
29
 
19
- - uses: outernet-foundation/release-devkit/.github/actions/publish@098b8e918c50397fef2f6d7ed1bf2452c16267d6
30
+ - name: Publish
31
+ run: uvx --from release-devkit==0.1.3 publish-packages --config publish-config.json
@@ -8,7 +8,7 @@ The repo and package renamed from `unity-buildkit` to `unity-devkit` (2026-09-21
8
8
 
9
9
  ## Release flow
10
10
 
11
- Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's publish composite action (pinned by SHA; the uvx invocation runs inside the caller's job, keeping OIDC identity local), never a project dependency (unity-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `unity-devkit-v*` tags are the version ledger (first release `0.1.0`, patch-auto thereafter). API-breaking changes ship with a manually bumped version — patch-auto assumes additive changes.
11
+ Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-packages` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (unity-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `unity-devkit-v*` tags are the version ledger (first release `0.1.0`, patch-auto thereafter). API-breaking changes ship with a manually bumped version — patch-auto assumes additive changes.
12
12
 
13
13
  ## Shape
14
14
 
@@ -39,7 +39,7 @@ Supporting modules: `projects.py` (manifest schema + discovery), `unity.py` (edi
39
39
 
40
40
  **Entry-point names are the workflow contract.** The hosted `unity-build.yml` invokes `unity-matrix`, `unity-license-tag`, `activate-unity-license`, and `build-unity` by name via `uv run` in the caller's checkout (the caller's venv supplies this package). Consumers pin the workflow by pushed SHA and the package by PyPI version independently; the two pins stay compatible as long as the entry-point names and flags hold, so treat those as a public API.
41
41
 
42
- **The environment-lookup helpers are cross-repo Python API.** `unity.find_editor_for_version` (editor ladder: `unity-editor` on PATH → `/opt/unity/<version>` → `~/Unity/Hub/Editor/<version>`; raises `ValueError` listing every searched path on a miss), `unity.editor_version` (`ProjectVersion.txt` parse; `None` for a missing file or unparseable content), and `projects.directories_containing` (+ `PRUNE_DIRECTORIES`) are imported by registry consumers — prepo's `sync` verb. Their signatures and miss behavior are contract, not internals. The `SystemExit`-raising wrappers (`find_unity_editor`, `read_editor_version`) exist for unity-devkit's own CLI paths and delegate to the core helpers.
42
+ **The environment-lookup helpers are cross-repo Python API.** `unity.find_editor_for_version` (editor ladder: `unity-editor` on PATH → `/opt/unity/<version>` → `~/Unity/Hub/Editor/<version>`; raises `ValueError` listing every searched path on a miss), `unity.editor_version` (`ProjectVersion.txt` parse; `None` for a missing file or unparseable content), and `projects.directories_containing` (+ `PRUNE_DIRECTORIES`) are imported by registry consumers — prepo's `sync` verb. Their signatures and miss behavior are contract, not internals. The `SystemExit`-raising wrapper `read_editor_version` exists for unity-devkit's own CLI paths and delegates to the core helper; `unity_batchmode_command` consumes the `ValueError`-raising `find_editor_for_version` and `read_editor_version` directly, so a missing editor surfaces as the raw `ValueError`.
43
43
 
44
44
  **Version stamping is opt-in via `tag_prefix`.** `build-unity` writes `.build-version.json` (consumed by the project's build `executeMethod`) only when the project's manifest declares `tag_prefix`; the version is the latest `<tag_prefix>-v*` git tag plus a run-number suffix. Projects without the field build unversioned. Consumer repos that release keep their own name→prefix map on their side — the two must agree for projects that release.
45
45
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unity-devkit
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Unity project discovery, build, license, and CI tooling
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unity-devkit"
3
- version = "0.1.8"
3
+ version = "0.1.10"
4
4
  description = "Unity project discovery, build, license, and CI tooling"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -9,7 +9,7 @@ import typer
9
9
  from bashrun.bash import bash, bash_check, bash_handoff, bash_output
10
10
 
11
11
  from .compile_unity import build_unity_project
12
- from .projects import UnityProject, load_unity_projects
12
+ from .projects import load_unity_projects
13
13
 
14
14
  INSTALLABLE_TARGETS = {"android-mobile", "magicleap", "linux64"}
15
15
  ADB_TARGETS = {"android-mobile", "magicleap"}
@@ -18,68 +18,6 @@ CACHE_ROOT = Path.home() / ".unity-devkit" / "builds"
18
18
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
19
19
 
20
20
 
21
- def _resolve_project(projects: dict[str, UnityProject], name: str) -> str:
22
- for project_name in projects:
23
- if project_name.lower() == name.lower():
24
- return project_name
25
- valid = ", ".join(projects.keys())
26
- raise typer.BadParameter(f"Unknown project '{name}'. Valid projects: {valid}")
27
-
28
-
29
- def _resolve_target(project_config: UnityProject, project_name: str, target: str | None) -> str:
30
- installable = [build for build in (project_config.builds or []) if build in INSTALLABLE_TARGETS]
31
- if target is None:
32
- if len(installable) == 1:
33
- return installable[0]
34
- valid = ", ".join(installable) if installable else "(none)"
35
- raise typer.BadParameter(
36
- f"--target is required for {project_name} (multiple installable targets). Valid targets: {valid}"
37
- )
38
- for build in installable:
39
- if build.lower() == target.lower():
40
- return build
41
- valid = ", ".join(installable)
42
- raise typer.BadParameter(f"No installable target '{target}' for {project_name}. Valid targets: {valid}")
43
-
44
-
45
- def _current_git_branch() -> str:
46
- branch = bash_output("git rev-parse --abbrev-ref HEAD").strip()
47
- if branch == "HEAD":
48
- raise typer.BadParameter("HEAD is detached; pass --branch explicitly")
49
- return branch
50
-
51
-
52
- def _find_run_id(artifact_name: str, branch: str) -> str:
53
- owner_repo = bash_output("gh repo view --json nameWithOwner --jq .nameWithOwner").strip()
54
- output = bash_output(
55
- f"gh api repos/{owner_repo}/actions/artifacts --method GET -f name={artifact_name} -f per_page=10 --jq .artifacts"
56
- )
57
- artifacts: list[dict[str, Any]] = json.loads(output)
58
- for artifact in artifacts:
59
- if artifact["workflow_run"]["head_branch"] == branch:
60
- return str(artifact["workflow_run"]["id"])
61
- print(f"No artifact '{artifact_name}' found on branch '{branch}'")
62
- raise SystemExit(1)
63
-
64
-
65
- def _download_artifact(run_id: str, artifact_name: str) -> Path:
66
- cache_path = CACHE_ROOT / run_id / artifact_name
67
- if cache_path.is_dir() and any(cache_path.iterdir()):
68
- print(f"Using cached artifact: {cache_path}")
69
- return cache_path
70
- cache_path.mkdir(parents=True, exist_ok=True)
71
- bash(f"gh run download {run_id} --name {artifact_name} --dir {cache_path}")
72
- return cache_path
73
-
74
-
75
- def _find_linux_executable(artifact_path: Path) -> Path:
76
- for item in artifact_path.iterdir():
77
- if item.is_file() and (artifact_path / f"{item.stem}_Data").is_dir():
78
- return item
79
- print("No linux64 executable found in artifact (expected a file with a matching _Data/ directory)")
80
- raise SystemExit(1)
81
-
82
-
83
21
  @app.command()
84
22
  def main(
85
23
  project: Annotated[str, typer.Option("--project", "-p", help="Unity project name")],
@@ -116,8 +54,26 @@ def main(
116
54
  ] = False,
117
55
  ) -> None:
118
56
  projects = load_unity_projects()
119
- project_name = _resolve_project(projects, project)
120
- target_name = _resolve_target(projects[project_name], project_name, target)
57
+
58
+ project_name = next((name for name in projects if name.lower() == project.lower()), None)
59
+ if project_name is None:
60
+ valid = ", ".join(projects.keys())
61
+ raise typer.BadParameter(f"Unknown project '{project}'. Valid projects: {valid}")
62
+
63
+ project_config = projects[project_name]
64
+ installable = [build for build in (project_config.builds or []) if build in INSTALLABLE_TARGETS]
65
+ if target is None:
66
+ if len(installable) != 1:
67
+ valid = ", ".join(installable) if installable else "(none)"
68
+ raise typer.BadParameter(
69
+ f"--target is required for {project_name} (multiple installable targets). Valid targets: {valid}"
70
+ )
71
+ target_name = installable[0]
72
+ else:
73
+ target_name = next((build for build in installable if build.lower() == target.lower()), None)
74
+ if target_name is None:
75
+ valid = ", ".join(installable)
76
+ raise typer.BadParameter(f"No installable target '{target}' for {project_name}. Valid targets: {valid}")
121
77
 
122
78
  if serial and target_name not in ADB_TARGETS:
123
79
  print(f"Warning: --serial is ignored for target '{target_name}'")
@@ -130,13 +86,60 @@ def main(
130
86
  executables = [path for path in produced if path.suffix == ".exe"]
131
87
  else:
132
88
  artifact_name = f"{project_name}-{target_name}"
133
- resolved_branch = branch or _current_git_branch()
134
- run_id = str(run) if run else _find_run_id(artifact_name, resolved_branch)
89
+
90
+ resolved_branch = branch
91
+ if not resolved_branch:
92
+ resolved_branch = bash_output("git rev-parse --abbrev-ref HEAD").strip()
93
+ if resolved_branch == "HEAD":
94
+ raise typer.BadParameter("HEAD is detached; pass --branch explicitly")
95
+
96
+ if run:
97
+ run_id = str(run)
98
+ else:
99
+ owner_repo = bash_output("gh repo view --json nameWithOwner --jq .nameWithOwner").strip()
100
+ output = bash_output(
101
+ f"gh api repos/{owner_repo}/actions/artifacts --method GET -f name={artifact_name}"
102
+ f" -f per_page=10 --jq .artifacts"
103
+ )
104
+ artifacts: list[dict[str, Any]] = json.loads(output)
105
+ run_id = next(
106
+ (
107
+ str(artifact["workflow_run"]["id"])
108
+ for artifact in artifacts
109
+ if artifact["workflow_run"]["head_branch"] == resolved_branch
110
+ ),
111
+ None,
112
+ )
113
+ if run_id is None:
114
+ print(f"No artifact '{artifact_name}' found on branch '{resolved_branch}'")
115
+ raise SystemExit(1)
116
+
135
117
  print(f"Run: {run_id}")
136
118
  print(f"Artifact: {artifact_name}")
137
- download_path = _download_artifact(run_id, artifact_name)
138
- apks = sorted(download_path.rglob("*.apk"))
139
- executables = [_find_linux_executable(download_path)] if target_name == "linux64" else []
119
+
120
+ cache_path = CACHE_ROOT / run_id / artifact_name
121
+ if cache_path.is_dir() and any(cache_path.iterdir()):
122
+ print(f"Using cached artifact: {cache_path}")
123
+ else:
124
+ cache_path.mkdir(parents=True, exist_ok=True)
125
+ bash(f"gh run download {run_id} --name {artifact_name} --dir {cache_path}")
126
+
127
+ apks = sorted(cache_path.rglob("*.apk"))
128
+
129
+ executables = []
130
+ if target_name == "linux64":
131
+ executable = next(
132
+ (
133
+ item
134
+ for item in cache_path.iterdir()
135
+ if item.is_file() and (cache_path / f"{item.stem}_Data").is_dir()
136
+ ),
137
+ None,
138
+ )
139
+ if executable is None:
140
+ print("No linux64 executable found in artifact (expected a file with a matching _Data/ directory)")
141
+ raise SystemExit(1)
142
+ executables = [executable]
140
143
 
141
144
  if target_name in ADB_TARGETS:
142
145
  if not apks:
@@ -145,11 +148,11 @@ def main(
145
148
 
146
149
  print(f"Installing: {apks[0].name}")
147
150
  adb_prefix = f"adb -s {serial}" if serial else "adb"
148
- package = projects[project_name].package
151
+ package = project_config.package
149
152
  if package:
150
153
  bash_check(f"{adb_prefix} uninstall {package}")
151
154
  bash(f"{adb_prefix} install {apks[0]}")
152
- permissions = projects[project_name].grant_permissions
155
+ permissions = project_config.grant_permissions
153
156
  if permissions and not no_grant_permissions:
154
157
  if not package:
155
158
  raise typer.BadParameter(
@@ -24,7 +24,8 @@ settings = Settings.model_validate({})
24
24
  activate_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
25
25
 
26
26
 
27
- def activate(oras_push: bool) -> None:
27
+ @activate_app.command()
28
+ def activate_main(oras_push: bool = typer.Option(False, help="Push activated ULF to ORAS cache")) -> None:
28
29
  configure_git(settings.github_workspace)
29
30
  install_oras()
30
31
 
@@ -44,8 +45,3 @@ def activate(oras_push: bool) -> None:
44
45
 
45
46
  if oras_push and not cache_hit:
46
47
  save(settings.cache_registry, "unity-license", tag, license_directory, ["Unity_lic.ulf"])
47
-
48
-
49
- @activate_app.command()
50
- def activate_main(oras_push: bool = typer.Option(False, help="Push activated ULF to ORAS cache")) -> None:
51
- activate(oras_push)
@@ -32,82 +32,6 @@ QUIET_FAILURE_SIGNATURES = ("An error occurred while resolving packages:",)
32
32
  QUIET_FAILURE_BLOCK_LINE_LIMIT = 20
33
33
 
34
34
 
35
- def find_unity_editor(project_path: Path) -> str:
36
- try:
37
- editor = find_editor_for_version(read_editor_version(project_path))
38
- except ValueError as error:
39
- raise SystemExit(str(error)) from error
40
- return str(editor)
41
-
42
-
43
- def find_editor_for_version(version: str) -> Path:
44
- path_editor = shutil.which("unity-editor")
45
- if path_editor:
46
- return Path(path_editor)
47
-
48
- if sys.platform == "win32":
49
- candidates = [Path(f"C:/Program Files/Unity/Hub/Editor/{version}/Editor/Unity.exe")]
50
- else:
51
- candidates = [
52
- Path(f"/opt/unity/{version}/Editor/Unity"),
53
- Path.home() / f"Unity/Hub/Editor/{version}/Editor/Unity",
54
- ]
55
-
56
- for candidate in candidates:
57
- if candidate.is_file():
58
- return candidate
59
-
60
- searched = ", ".join(str(candidate) for candidate in candidates)
61
- raise ValueError(f"Cannot find Unity {version} editor. Searched: {searched}")
62
-
63
-
64
- def read_editor_version(project_path: Path) -> str:
65
- version_file = project_path / "ProjectSettings" / "ProjectVersion.txt"
66
- version = editor_version(project_path)
67
- if version is not None:
68
- return version
69
- if not version_file.exists():
70
- raise SystemExit(f"Cannot find {version_file} — is this a Unity project?")
71
- raise SystemExit(f"Cannot parse editor version from {version_file}")
72
-
73
-
74
- def editor_version(project_path: Path) -> str | None:
75
- version_file = project_path / "ProjectSettings" / "ProjectVersion.txt"
76
- if not version_file.exists():
77
- return None
78
- for line in version_file.read_text().splitlines():
79
- if line.startswith("m_EditorVersion:"):
80
- return line.split(":", 1)[1].strip()
81
- return None
82
-
83
-
84
- def prepare_unity_project(project_path: Path) -> None:
85
- stale_lockfile = project_path / "Temp" / "UnityLockfile"
86
- if stale_lockfile.exists():
87
- stale_lockfile.unlink()
88
-
89
- bash("dotnet tool restore")
90
- bash(f"dotnet nugetforunity restore {project_path}")
91
-
92
-
93
- def unity_batchmode_command(project_path: Path, nographics: bool = True, *, auto_quit: bool = True) -> str:
94
- editor = find_unity_editor(project_path)
95
- # Player builds need a real GfxDevice: Unity 6 compresses Android textures (ASTC/ETC2) on the
96
- # GPU, and under -nographics the Null device falls back to a path that produces corrupt textures.
97
- # xvfb-run (added below) supplies the display the dropped -nographics would otherwise stand in for.
98
- graphics_flag = " -nographics" if nographics else ""
99
- quit_flag = " -quit" if auto_quit else ""
100
- command = f"{editor} -batchmode{graphics_flag}{quit_flag} -projectPath {project_path.resolve()}"
101
- if sys.platform != "win32":
102
- if shutil.which("xvfb-run"):
103
- command = f"xvfb-run {command}"
104
- # Unity runs `adb kill-server` on Android build teardown; strip the
105
- # env var so the kill lands on a local daemon, not whatever
106
- # ADB_SERVER_SOCKET points at.
107
- command = f"env -u ADB_SERVER_SOCKET {command}"
108
- return command
109
-
110
-
111
35
  def run_unity_batchmode(
112
36
  project_path: Path,
113
37
  extra_flags: str = "",
@@ -129,17 +53,9 @@ def run_unity_batchmode(
129
53
  bash(command, log_path=log_path)
130
54
  except CalledProcessError as error:
131
55
  returncode = error.returncode
132
- failure_block = quiet_failure_block(log_path)
133
- if failure_block is not None:
134
- raise SystemExit(f"Unity reported a package-manager failure (exit code {returncode}):\n{failure_block}")
135
- if returncode != 0 and strict_exit:
136
- raise SystemExit(f"Unity exited {returncode}; full editor log at {log_path}")
137
- if returncode != 0:
138
- print(f" WARNING: Unity exited {returncode}; no package-manager failure found — full editor log at {log_path}")
139
-
140
56
 
141
- def quiet_failure_block(log_path: Path) -> str | None:
142
57
  lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
58
+ failure_block = None
143
59
  for index, line in enumerate(lines):
144
60
  if not any(signature in line for signature in QUIET_FAILURE_SIGNATURES):
145
61
  continue
@@ -148,7 +64,73 @@ def quiet_failure_block(log_path: Path) -> str | None:
148
64
  if block and not candidate.strip():
149
65
  break
150
66
  block.append(candidate)
151
- return "\n".join(block)
67
+ failure_block = "\n".join(block)
68
+ break
69
+
70
+ if failure_block is not None:
71
+ raise SystemExit(f"Unity reported a package-manager failure (exit code {returncode}):\n{failure_block}")
72
+ if returncode != 0 and strict_exit:
73
+ raise SystemExit(f"Unity exited {returncode}; full editor log at {log_path}")
74
+ if returncode != 0:
75
+ print(f" WARNING: Unity exited {returncode}; no package-manager failure found — full editor log at {log_path}")
76
+
77
+
78
+ def unity_batchmode_command(project_path: Path, nographics: bool = True, *, auto_quit: bool = True) -> str:
79
+ editor = str(find_editor_for_version(read_editor_version(project_path)))
80
+ # Player builds need a real GfxDevice: Unity 6 compresses Android textures (ASTC/ETC2) on the
81
+ # GPU, and under -nographics the Null device falls back to a path that produces corrupt textures.
82
+ # xvfb-run (added below) supplies the display the dropped -nographics would otherwise stand in for.
83
+ graphics_flag = " -nographics" if nographics else ""
84
+ quit_flag = " -quit" if auto_quit else ""
85
+ command = f"{editor} -batchmode{graphics_flag}{quit_flag} -projectPath {project_path.resolve()}"
86
+ if sys.platform != "win32":
87
+ if shutil.which("xvfb-run"):
88
+ command = f"xvfb-run {command}"
89
+ # Unity runs `adb kill-server` on Android build teardown; strip the
90
+ # env var so the kill lands on a local daemon, not whatever
91
+ # ADB_SERVER_SOCKET points at.
92
+ command = f"env -u ADB_SERVER_SOCKET {command}"
93
+ return command
94
+
95
+
96
+ def read_editor_version(project_path: Path) -> str:
97
+ version_file = project_path / "ProjectSettings" / "ProjectVersion.txt"
98
+ version = editor_version(project_path)
99
+ if version is not None:
100
+ return version
101
+ if not version_file.exists():
102
+ raise SystemExit(f"Cannot find {version_file} — is this a Unity project?")
103
+ raise SystemExit(f"Cannot parse editor version from {version_file}")
104
+
105
+
106
+ def find_editor_for_version(version: str) -> Path:
107
+ path_editor = shutil.which("unity-editor")
108
+ if path_editor:
109
+ return Path(path_editor)
110
+
111
+ if sys.platform == "win32":
112
+ candidates = [Path(f"C:/Program Files/Unity/Hub/Editor/{version}/Editor/Unity.exe")]
113
+ else:
114
+ candidates = [
115
+ Path(f"/opt/unity/{version}/Editor/Unity"),
116
+ Path.home() / f"Unity/Hub/Editor/{version}/Editor/Unity",
117
+ ]
118
+
119
+ for candidate in candidates:
120
+ if candidate.is_file():
121
+ return candidate
122
+
123
+ searched = ", ".join(str(candidate) for candidate in candidates)
124
+ raise ValueError(f"Cannot find Unity {version} editor. Searched: {searched}")
125
+
126
+
127
+ def editor_version(project_path: Path) -> str | None:
128
+ version_file = project_path / "ProjectSettings" / "ProjectVersion.txt"
129
+ if not version_file.exists():
130
+ return None
131
+ for line in version_file.read_text().splitlines():
132
+ if line.startswith("m_EditorVersion:"):
133
+ return line.split(":", 1)[1].strip()
152
134
  return None
153
135
 
154
136
 
@@ -171,3 +153,12 @@ def resolve_unity_build(project: str, build: str) -> tuple[UnityProject, str, st
171
153
  raise SystemExit(f"No platform config for build '{build}'. Valid: {', '.join(PLATFORM_CONFIGS)}")
172
154
 
173
155
  return project_config, PLATFORM_CONFIGS[build]["build_flag"], execute_method
156
+
157
+
158
+ def prepare_unity_project(project_path: Path) -> None:
159
+ stale_lockfile = project_path / "Temp" / "UnityLockfile"
160
+ if stale_lockfile.exists():
161
+ stale_lockfile.unlink()
162
+
163
+ bash("dotnet tool restore")
164
+ bash(f"dotnet nugetforunity restore {project_path}")
@@ -1,15 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- concurrency:
10
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11
- cancel-in-progress: true
12
-
13
- jobs:
14
- check:
15
- uses: outernet-foundation/python-devkit/.github/workflows/check.yml@19b4656947784ce1d3225e35a96a21c45f7725f9
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -8,6 +8,15 @@ from .unity import prepare_unity_project, resolve_unity_build, run_unity_batchmo
8
8
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
9
9
 
10
10
 
11
+ @app.command()
12
+ def compile_unity(
13
+ project: Annotated[str, typer.Option(help="Unity project name (directory containing unity-build.json)")],
14
+ build: Annotated[str, typer.Option(help="Build target from the project's builds list (e.g. android-mobile)")],
15
+ ) -> None:
16
+ for artifact in build_unity_project(project, build):
17
+ print(f"Built: {artifact}")
18
+
19
+
11
20
  def build_unity_project(project: str, build: str) -> list[Path]:
12
21
  project_config, build_flag, execute_method = resolve_unity_build(project, build)
13
22
  project_path = project_config.path
@@ -29,15 +38,6 @@ def build_unity_project(project: str, build: str) -> list[Path]:
29
38
  return produced
30
39
 
31
40
 
32
- @app.command()
33
- def compile_unity(
34
- project: Annotated[str, typer.Option(help="Unity project name (directory containing unity-build.json)")],
35
- build: Annotated[str, typer.Option(help="Build target from the project's builds list (e.g. android-mobile)")],
36
- ) -> None:
37
- for artifact in build_unity_project(project, build):
38
- print(f"Built: {artifact}")
39
-
40
-
41
41
  def snapshot_artifacts(build_directory: Path) -> dict[Path, int]:
42
42
  if not build_directory.is_dir():
43
43
  return {}
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import os
4
3
  from datetime import UTC, datetime
4
+ import os
5
5
  from pathlib import Path
6
6
 
7
7
  import typer
@@ -10,15 +10,6 @@ from pydantic_settings import BaseSettings
10
10
  from ci_devkit.cache import restore
11
11
 
12
12
 
13
- # LICENSE_CACHE_TAG env pins the tag for a whole CI run; without it, each call
14
- # re-reads "now UTC" and a run straddling midnight save/restore-misses itself.
15
- def license_cache_tag() -> str:
16
- override = os.environ.get("LICENSE_CACHE_TAG")
17
- if override:
18
- return override
19
- return f"v-{datetime.now(UTC).strftime('%Y-%m-%d')}"
20
-
21
-
22
13
  class Settings(BaseSettings):
23
14
  cache_registry: str
24
15
 
@@ -36,3 +27,12 @@ def restore_license() -> None:
36
27
  license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
37
28
  license_directory.mkdir(parents=True, exist_ok=True)
38
29
  restore(settings.cache_registry, "unity-license", license_cache_tag(), license_directory, required=True)
30
+
31
+
32
+ # LICENSE_CACHE_TAG env pins the tag for a whole CI run; without it, each call
33
+ # re-reads "now UTC" and a run straddling midnight save/restore-misses itself.
34
+ def license_cache_tag() -> str:
35
+ override = os.environ.get("LICENSE_CACHE_TAG")
36
+ if override:
37
+ return override
38
+ return f"v-{datetime.now(UTC).strftime('%Y-%m-%d')}"
File without changes