unity-devkit 0.1.20__tar.gz → 0.1.21__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 (30) hide show
  1. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/PKG-INFO +1 -1
  2. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/pyproject.toml +3 -4
  3. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/ci_build_unity.py +21 -12
  4. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/compile_check_unity.py +22 -0
  5. unity_devkit-0.1.21/src/unity_devkit/license.py +32 -0
  6. unity_devkit-0.1.21/src/unity_devkit/license_restore.py +84 -0
  7. unity_devkit-0.1.21/src/unity_devkit/matrix.py +76 -0
  8. unity_devkit-0.1.21/src/unity_devkit/upm_cache.py +39 -0
  9. unity_devkit-0.1.21/tests/test_matrix.py +118 -0
  10. unity_devkit-0.1.21/tests/test_upm_cache.py +37 -0
  11. unity_devkit-0.1.20/src/unity_devkit/dispatch_workflow_generator.py +0 -268
  12. unity_devkit-0.1.20/src/unity_devkit/license.py +0 -47
  13. unity_devkit-0.1.20/src/unity_devkit/license_restore.py +0 -38
  14. unity_devkit-0.1.20/src/unity_devkit/matrix.py +0 -52
  15. unity_devkit-0.1.20/tests/test_dispatch_workflow_generator.py +0 -225
  16. unity_devkit-0.1.20/tests/test_matrix.py +0 -61
  17. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/.gitignore +0 -0
  18. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/__init__.py +0 -0
  19. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/build_unity.py +0 -0
  20. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/install.py +0 -0
  21. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/lock_unity.py +0 -0
  22. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/player_build.py +0 -0
  23. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/projects.py +0 -0
  24. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/py.typed +0 -0
  25. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/src/unity_devkit/test_unity.py +0 -0
  26. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/tests/fixtures/environment-dump.json +0 -0
  27. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/tests/test_build_player.py +0 -0
  28. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/tests/test_prepare.py +0 -0
  29. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/tests/test_projects.py +0 -0
  30. {unity_devkit-0.1.20 → unity_devkit-0.1.21}/tests/test_run_unity_batchmode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unity-devkit
3
- Version: 0.1.20
3
+ Version: 0.1.21
4
4
  Summary: Unity build, license, and CI tooling
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: bashrun>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unity-devkit"
3
- version = "0.1.20"
3
+ version = "0.1.21"
4
4
  description = "Unity build, license, and CI tooling"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -14,15 +14,14 @@ dependencies = [
14
14
  [project.scripts]
15
15
  activate-unity-license = "unity_devkit.license:activate_app"
16
16
  build-unity = "unity_devkit.build_unity:app"
17
- build-unity-matrix = "unity_devkit.matrix:build_matrix"
17
+ build-unity-matrix = "unity_devkit.matrix:build_app"
18
18
  ci-build-unity = "unity_devkit.ci_build_unity:app"
19
19
  compile-check-unity = "unity_devkit.compile_check_unity:app"
20
- compile-check-unity-matrix = "unity_devkit.matrix:compile_check_matrix"
20
+ compile-check-unity-matrix = "unity_devkit.matrix:compile_check_app"
21
21
  install = "unity_devkit.install:app"
22
22
  lock-unity = "unity_devkit.lock_unity:app"
23
23
  test-unity = "unity_devkit.test_unity:app"
24
24
  unity-license-tag = "unity_devkit.license_restore:tag_app"
25
- unity-dispatch = "unity_devkit.dispatch_workflow_generator:app"
26
25
 
27
26
  [build-system]
28
27
  requires = ["hatchling"]
@@ -10,10 +10,11 @@ from pydantic_settings import BaseSettings
10
10
 
11
11
  from ci_devkit.cache import restore, save
12
12
  from ci_devkit.ci_step import ci_step
13
- from .license_restore import restore_license
14
13
  from ci_devkit.setup import configure_git, install_dotnet
15
14
  from ci_devkit.setup_oras import install_oras
15
+ from .license_restore import restore_or_activate_license
16
16
  from .player_build import build_player, parse_environment_fields, resolve_unity_project
17
+ from .upm_cache import restore_upm_cache, save_upm_cache
17
18
 
18
19
 
19
20
  class Settings(BaseSettings):
@@ -26,7 +27,6 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
26
27
  @app.command()
27
28
  def ci_build_unity(
28
29
  project: Annotated[str, typer.Option(help="Project name")],
29
- project_path: Annotated[Path, typer.Option(help="Path to Unity project")],
30
30
  platform: Annotated[str, typer.Option(help="Target platform")],
31
31
  cache_key: Annotated[str, typer.Option(help="Cache key prefix")],
32
32
  registry: Annotated[str, typer.Option(help="OCI registry path for the library cache")],
@@ -51,7 +51,9 @@ def ci_build_unity(
51
51
  configure_git(settings.github_workspace)
52
52
  install_dotnet("8.0")
53
53
  install_oras()
54
- restore_license()
54
+ restore_or_activate_license()
55
+ project_path = resolve_unity_project(project).path
56
+ restore_upm_cache(project_path)
55
57
 
56
58
  branch_slug = branch.replace("/", "-")
57
59
  tag = f"{cache_key}-{platform}-{branch_slug}"
@@ -61,13 +63,6 @@ def ci_build_unity(
61
63
  with ci_step("Restore library cache"):
62
64
  restore(registry, "unity-library", tag, Path("."), fallback_tags=fallback_tags)
63
65
 
64
- with ci_step("Prepare build"):
65
- unity_project_path = resolve_unity_project(project).path
66
- if unity_project_path.resolve() != project_path.resolve():
67
- raise SystemExit(
68
- f"--project-path {project_path} does not match discovered project '{project}' at {unity_project_path}"
69
- )
70
-
71
66
  with ci_step(f"Build {project} [{platform}]"):
72
67
  build_player(
73
68
  project,
@@ -85,7 +80,11 @@ def ci_build_unity(
85
80
  if package_cache.exists():
86
81
  shutil.rmtree(package_cache)
87
82
 
88
- save(registry, "unity-library", tag, Path("."), [f"{project_path}/Library/"])
83
+ relative_project_path = project_path.relative_to(Path.cwd())
84
+ save(registry, "unity-library", tag, Path("."), [f"{relative_project_path}/Library/"])
85
+
86
+ with ci_step("Save UPM cache"):
87
+ save_upm_cache(project_path)
89
88
 
90
89
  with ci_step("Collect build artifacts"):
91
90
  build_directory = project_path / "Build"
@@ -98,6 +97,10 @@ def ci_build_unity(
98
97
  for file in build_directory.rglob("*"):
99
98
  if file.suffix in {".apk", ".exe"}:
100
99
  shutil.copy2(file, artifact_directory / file.name)
100
+ for report in build_directory.rglob("BuildReport.json"):
101
+ destination = artifact_directory / report.relative_to(build_directory)
102
+ destination.parent.mkdir(parents=True, exist_ok=True)
103
+ shutil.copy2(report, destination)
101
104
 
102
105
  with ci_step("Push build artifacts"):
103
106
  artifact_directory = Path("/tmp/unity-builds")
@@ -117,7 +120,13 @@ def ci_build_unity(
117
120
  finally:
118
121
  tar_path.unlink(missing_ok=True)
119
122
  else:
120
- joined = " ".join(sorted(p.name for p in artifact_directory.iterdir() if p.is_file()))
123
+ joined = " ".join(
124
+ sorted(
125
+ str(path.relative_to(artifact_directory))
126
+ for path in artifact_directory.rglob("*")
127
+ if path.is_file()
128
+ )
129
+ )
121
130
  for build_tag in tags:
122
131
  bash(f"oras push {reference_base}:{build_tag} {joined}", cwd=artifact_directory)
123
132
  print(f"Pushed build artifacts: {reference_base} ({', '.join(tags)})")
@@ -1,8 +1,21 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import Annotated
2
4
 
3
5
  import typer
6
+ from pydantic_settings import BaseSettings
4
7
 
8
+ from ci_devkit.ci_step import ci_step
9
+ from ci_devkit.setup import configure_git
10
+ from ci_devkit.setup_oras import install_oras
11
+ from .license_restore import restore_or_activate_license
5
12
  from .player_build import prepare_unity_project, resolve_unity_project, run_unity_batchmode
13
+ from .upm_cache import restore_upm_cache, save_upm_cache
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ github_workspace: str
18
+
6
19
 
7
20
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
8
21
 
@@ -21,7 +34,15 @@ def compile_check_unity(
21
34
  typer.Option(help="Startup build target (e.g. Android) for sessions that must open on a non-default platform"),
22
35
  ] = None,
23
36
  ) -> None:
37
+ settings = Settings.model_validate({})
24
38
  project_path = resolve_unity_project(project).path
39
+
40
+ with ci_step("Setup"):
41
+ configure_git(settings.github_workspace)
42
+ install_oras()
43
+ restore_or_activate_license()
44
+ restore_upm_cache(project_path)
45
+
25
46
  extra_flags = ""
26
47
  if build_target:
27
48
  extra_flags += f" -buildTarget {build_target}"
@@ -30,4 +51,5 @@ def compile_check_unity(
30
51
  print(f"Compile-checking {project}...")
31
52
  prepare_unity_project(project_path)
32
53
  run_unity_batchmode(project_path, extra_flags, extra_failure_signatures=COMPILE_ERROR_SIGNATURES)
54
+ save_upm_cache(project_path)
33
55
  print(" Compiles clean")
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from pydantic_settings import BaseSettings
7
+
8
+ from ci_devkit.setup import configure_git
9
+ from ci_devkit.setup_oras import install_oras
10
+ from .license_restore import activate_license, restore_or_activate_license
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ github_workspace: str
15
+
16
+
17
+ settings = Settings.model_validate({})
18
+ activate_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
19
+
20
+
21
+ @activate_app.command()
22
+ def activate_main(
23
+ oras_push: Annotated[
24
+ bool, typer.Option(help="Restore from the ORAS cache on miss and push the activated ULF back")
25
+ ] = False,
26
+ ) -> None:
27
+ configure_git(settings.github_workspace)
28
+ install_oras()
29
+ if oras_push:
30
+ restore_or_activate_license()
31
+ else:
32
+ activate_license()
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import time
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from bashrun.bash import CalledProcessError, bash
11
+ from pydantic_settings import BaseSettings
12
+
13
+ from ci_devkit.cache import restore, save
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ cache_registry: str
18
+ unity_email: str
19
+ unity_password: str
20
+ unity_serial: str
21
+
22
+
23
+ tag_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
24
+
25
+ LICENSE_CACHE_NAME = "unity-license"
26
+ ACTIVATION_ATTEMPTS = 3
27
+ RETRY_JITTER_SECONDS = (30.0, 120.0)
28
+
29
+
30
+ @tag_app.command()
31
+ def tag_main() -> None:
32
+ print(license_cache_tag())
33
+
34
+
35
+ def restore_or_activate_license() -> None:
36
+ settings = Settings.model_validate({})
37
+ license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
38
+ license_directory.mkdir(parents=True, exist_ok=True)
39
+ tag = license_cache_tag()
40
+
41
+ for attempt in range(1, ACTIVATION_ATTEMPTS + 1):
42
+ if restore(settings.cache_registry, LICENSE_CACHE_NAME, tag, license_directory):
43
+ return
44
+ if license_activated():
45
+ save(settings.cache_registry, LICENSE_CACHE_NAME, tag, license_directory, ["Unity_lic.ulf"])
46
+ return
47
+ if attempt == ACTIVATION_ATTEMPTS:
48
+ break
49
+ wait = random.SystemRandom().uniform(*RETRY_JITTER_SECONDS)
50
+ print(
51
+ f"License activation rejected (attempt {attempt}/{ACTIVATION_ATTEMPTS}) — "
52
+ f"waiting {wait:.0f}s, then re-restoring and retrying"
53
+ )
54
+ time.sleep(wait)
55
+
56
+ raise SystemExit(
57
+ f"Unity license activation failed after {ACTIVATION_ATTEMPTS} attempts — "
58
+ "check UNITY_EMAIL/UNITY_PASSWORD/UNITY_SERIAL and the license seat"
59
+ )
60
+
61
+
62
+ def activate_license() -> None:
63
+ settings = Settings.model_validate({})
64
+ bash(
65
+ f'unity-editor -batchmode -nographics -quit -serial "{settings.unity_serial}"'
66
+ f' -username "{settings.unity_email}" -password "{settings.unity_password}" -logFile /dev/stdout'
67
+ )
68
+
69
+
70
+ def license_activated() -> bool:
71
+ try:
72
+ activate_license()
73
+ except CalledProcessError:
74
+ return False
75
+ return True
76
+
77
+
78
+ # LICENSE_CACHE_TAG env pins the tag for a whole CI run; without it, each call
79
+ # re-reads "now UTC" and a run straddling midnight save/restore-misses itself.
80
+ def license_cache_tag() -> str:
81
+ override = os.environ.get("LICENSE_CACHE_TAG")
82
+ if override:
83
+ return override
84
+ return f"v-{datetime.now(UTC).strftime('%Y-%m-%d')}"
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from .license_restore import license_cache_tag
10
+ from .player_build import LICENSE_IMAGE_MODULE, PLATFORM_CONFIGS, UNITYCI_IMAGE_REVISION, read_editor_version
11
+ from .projects import ProjectEntry, discover_projects
12
+
13
+ build_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
14
+ compile_check_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
15
+
16
+
17
+ @build_app.command()
18
+ def build_matrix(
19
+ project: Annotated[
20
+ str | None, typer.Option(help="Restrict the matrix to one project (dispatch scoping in multi-app repos)")
21
+ ] = None,
22
+ ) -> None:
23
+ projects = filtered_projects(project)
24
+ matrix: list[dict[str, str]] = []
25
+
26
+ for name, entry in projects.items():
27
+ if not entry.builds:
28
+ continue
29
+ version = read_editor_version(entry.path)
30
+ for platform in entry.builds:
31
+ image_module = PLATFORM_CONFIGS[platform]["unityci_image_module"]
32
+ matrix.append({
33
+ "project": str(entry.path.relative_to(Path.cwd())),
34
+ "project-name": name,
35
+ "cache-key": name.lower(),
36
+ "platform": platform,
37
+ "unityci-image-module": image_module,
38
+ "editor-image": f"unityci/editor:{version}-{image_module}-{UNITYCI_IMAGE_REVISION}",
39
+ })
40
+
41
+ if not matrix:
42
+ raise SystemExit(
43
+ "No projects with builds declared — build-unity-matrix needs at least one unity-devkit.json "
44
+ "with a non-empty 'platforms' map"
45
+ )
46
+ print(f"matrix={json.dumps({'include': matrix})}")
47
+ print(f"license-tag={license_cache_tag()}")
48
+
49
+
50
+ @compile_check_app.command()
51
+ def compile_check_matrix(
52
+ project: Annotated[
53
+ str | None, typer.Option(help="Restrict the matrix to one project (dispatch scoping in multi-app repos)")
54
+ ] = None,
55
+ ) -> None:
56
+ projects = filtered_projects(project)
57
+ matrix: list[dict[str, str]] = []
58
+
59
+ for name, entry in projects.items():
60
+ version = read_editor_version(entry.path)
61
+ matrix.append({
62
+ "project-name": name,
63
+ "editor-image": f"unityci/editor:{version}-{LICENSE_IMAGE_MODULE}-{UNITYCI_IMAGE_REVISION}",
64
+ })
65
+
66
+ print(f"matrix={json.dumps({'include': matrix})}")
67
+ print(f"license-tag={license_cache_tag()}")
68
+
69
+
70
+ def filtered_projects(project: str | None) -> dict[str, ProjectEntry]:
71
+ projects = discover_projects()
72
+ if project is None:
73
+ return projects
74
+ if project not in projects:
75
+ raise SystemExit(f"Unknown project '{project}'. Valid: {', '.join(projects)}")
76
+ return {project: projects[project]}
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ from pydantic_settings import BaseSettings
7
+
8
+ from ci_devkit.cache import restore, save
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ cache_registry: str
13
+
14
+
15
+ UPM_CACHE_NAME = "unity-upm"
16
+ UPM_CACHE_DIRECTORY = Path.home() / ".cache" / "Unity" / "upm"
17
+ UPM_MANIFESTS = ("manifest.json", "packages-lock.json")
18
+
19
+
20
+ def restore_upm_cache(project_path: Path) -> None:
21
+ settings = Settings.model_validate({})
22
+ UPM_CACHE_DIRECTORY.mkdir(parents=True, exist_ok=True)
23
+ restore(settings.cache_registry, UPM_CACHE_NAME, upm_cache_tag(project_path), UPM_CACHE_DIRECTORY)
24
+
25
+
26
+ def save_upm_cache(project_path: Path) -> None:
27
+ settings = Settings.model_validate({})
28
+ UPM_CACHE_DIRECTORY.mkdir(parents=True, exist_ok=True)
29
+ save(settings.cache_registry, UPM_CACHE_NAME, upm_cache_tag(project_path), UPM_CACHE_DIRECTORY, ["."])
30
+
31
+
32
+ # A never-resolved project has no packages-lock.json yet; hashing it as empty keeps the
33
+ # tag computable, and the lock written by that run produces the tag the next run restores.
34
+ def upm_cache_tag(project_path: Path) -> str:
35
+ digest = hashlib.sha256()
36
+ for name in UPM_MANIFESTS:
37
+ manifest = project_path / "Packages" / name
38
+ digest.update(manifest.read_bytes() if manifest.exists() else b"")
39
+ return digest.hexdigest()
@@ -0,0 +1,118 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from unity_devkit import matrix
7
+
8
+
9
+ def write_repository(tmp_path: Path, builds: list[str] | None = None) -> None:
10
+ project = tmp_path / "apps" / "Tool"
11
+ (project / "ProjectSettings").mkdir(parents=True)
12
+ (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
13
+ config: dict[str, object] = {"name": "tool"}
14
+ if builds is not None:
15
+ config["platforms"] = {platform: {} for platform in builds}
16
+ (project / "unity-devkit.json").write_text(json.dumps(config))
17
+
18
+
19
+ def test_matrix_emits_entries_from_catalog(
20
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
21
+ ) -> None:
22
+ write_repository(tmp_path, builds=["Linux"])
23
+ monkeypatch.chdir(tmp_path)
24
+ monkeypatch.delenv("LICENSE_CACHE_TAG", raising=False)
25
+
26
+ matrix.build_matrix()
27
+
28
+ lines = capsys.readouterr().out.splitlines()
29
+ include = json.loads(lines[0][len("matrix=") :])["include"]
30
+ assert include == [
31
+ {
32
+ "project": str(Path("apps") / "Tool"),
33
+ "project-name": "tool",
34
+ "cache-key": "tool",
35
+ "platform": "Linux",
36
+ "unityci-image-module": "linux-il2cpp",
37
+ "editor-image": "unityci/editor:6000.0.66f1-linux-il2cpp-3",
38
+ }
39
+ ]
40
+ assert lines[1].startswith("license-tag=v-")
41
+
42
+
43
+ def test_matrix_fails_without_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
44
+ write_repository(tmp_path)
45
+ monkeypatch.chdir(tmp_path)
46
+
47
+ with pytest.raises(SystemExit, match="No projects with builds declared"):
48
+ matrix.build_matrix()
49
+
50
+
51
+ def test_matrix_project_filter_scopes_to_one_project(
52
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
53
+ ) -> None:
54
+ write_repository(tmp_path, builds=["Linux"])
55
+ other = tmp_path / "apps" / "Other"
56
+ (other / "ProjectSettings").mkdir(parents=True)
57
+ (other / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
58
+ (other / "unity-devkit.json").write_text(json.dumps({"name": "other", "platforms": {"Linux": {}}}))
59
+ monkeypatch.chdir(tmp_path)
60
+
61
+ matrix.build_matrix(project="other")
62
+
63
+ lines = capsys.readouterr().out.splitlines()
64
+ include = json.loads(lines[0][len("matrix=") :])["include"]
65
+ assert [entry["project-name"] for entry in include] == ["other"]
66
+
67
+
68
+ def test_matrix_project_filter_fails_on_unknown_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
69
+ write_repository(tmp_path, builds=["Linux"])
70
+ monkeypatch.chdir(tmp_path)
71
+
72
+ with pytest.raises(SystemExit, match="Unknown project 'missing'"):
73
+ matrix.build_matrix(project="missing")
74
+
75
+
76
+ def test_matrix_honors_license_cache_tag_override(
77
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
78
+ ) -> None:
79
+ write_repository(tmp_path, builds=["Linux"])
80
+ monkeypatch.chdir(tmp_path)
81
+ monkeypatch.setenv("LICENSE_CACHE_TAG", "v-pinned")
82
+
83
+ matrix.build_matrix()
84
+
85
+ lines = capsys.readouterr().out.splitlines()
86
+ assert lines[1] == "license-tag=v-pinned"
87
+
88
+
89
+ def test_check_matrix_includes_path_only_projects(
90
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
91
+ ) -> None:
92
+ write_repository(tmp_path)
93
+ monkeypatch.chdir(tmp_path)
94
+ monkeypatch.delenv("LICENSE_CACHE_TAG", raising=False)
95
+
96
+ matrix.compile_check_matrix()
97
+
98
+ lines = capsys.readouterr().out.splitlines()
99
+ include = json.loads(lines[0][len("matrix=") :])["include"]
100
+ assert include == [{"project-name": "tool", "editor-image": "unityci/editor:6000.0.66f1-linux-il2cpp-3"}]
101
+ assert lines[1].startswith("license-tag=v-")
102
+
103
+
104
+ def test_check_matrix_project_filter_scopes_to_one_project(
105
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
106
+ ) -> None:
107
+ write_repository(tmp_path)
108
+ other = tmp_path / "apps" / "Other"
109
+ (other / "ProjectSettings").mkdir(parents=True)
110
+ (other / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
111
+ (other / "unity-devkit.json").write_text(json.dumps({"name": "other"}))
112
+ monkeypatch.chdir(tmp_path)
113
+
114
+ matrix.compile_check_matrix(project="other")
115
+
116
+ lines = capsys.readouterr().out.splitlines()
117
+ include = json.loads(lines[0][len("matrix=") :])["include"]
118
+ assert include == [{"project-name": "other", "editor-image": "unityci/editor:6000.0.66f1-linux-il2cpp-3"}]
@@ -0,0 +1,37 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+ from unity_devkit.upm_cache import upm_cache_tag
5
+
6
+
7
+ def write_packages(project_path: Path, manifest: str, lock: str | None) -> None:
8
+ packages = project_path / "Packages"
9
+ packages.mkdir(parents=True)
10
+ (packages / "manifest.json").write_text(manifest)
11
+ if lock is not None:
12
+ (packages / "packages-lock.json").write_text(lock)
13
+
14
+
15
+ def test_tag_hashes_both_manifests(tmp_path: Path) -> None:
16
+ write_packages(tmp_path, '{"a":1}', '{"b":2}')
17
+
18
+ expected = hashlib.sha256(b'{"a":1}{"b":2}').hexdigest()
19
+
20
+ assert upm_cache_tag(tmp_path) == expected
21
+
22
+
23
+ def test_tag_changes_when_either_manifest_changes(tmp_path: Path) -> None:
24
+ write_packages(tmp_path, '{"a":1}', '{"b":2}')
25
+ before = upm_cache_tag(tmp_path)
26
+
27
+ (tmp_path / "Packages" / "packages-lock.json").write_text('{"b":3}')
28
+
29
+ assert upm_cache_tag(tmp_path) != before
30
+
31
+
32
+ def test_tag_treats_missing_lock_as_empty(tmp_path: Path) -> None:
33
+ write_packages(tmp_path, '{"a":1}', None)
34
+
35
+ expected = hashlib.sha256(b'{"a":1}').hexdigest()
36
+
37
+ assert upm_cache_tag(tmp_path) == expected
@@ -1,268 +0,0 @@
1
- import json
2
- from collections import Counter
3
- from pathlib import Path
4
- from typing import Annotated
5
-
6
- import typer
7
- from pydantic import BaseModel, ConfigDict
8
-
9
- from .compile_check_unity import COMPILE_ERROR_SIGNATURES
10
- from .projects import discover_projects
11
- from .player_build import prepare_unity_project, run_unity_batchmode
12
-
13
- app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
14
-
15
- DUMP_EXECUTE_METHOD = "Outernet.PlayerBuild.DumpEnvironment"
16
- DISPATCH_INPUT_CAP = 25
17
- RESERVED_INPUT_IDS = frozenset({"environment-preset", "development"})
18
- NUMERIC_FIELD_TYPES = {
19
- "Byte",
20
- "SByte",
21
- "Int16",
22
- "UInt16",
23
- "Int32",
24
- "UInt32",
25
- "Int64",
26
- "UInt64",
27
- "Single",
28
- "Double",
29
- "Decimal",
30
- }
31
-
32
-
33
- class EnvironmentEnum(BaseModel):
34
- model_config = ConfigDict(extra="forbid")
35
-
36
- name: str
37
- values: list[str]
38
- flags: bool
39
-
40
-
41
- class EnvironmentFieldDump(BaseModel):
42
- model_config = ConfigDict(extra="forbid")
43
-
44
- path: str
45
- field_type: str
46
-
47
-
48
- class EnvironmentDump(BaseModel):
49
- model_config = ConfigDict(extra="forbid")
50
-
51
- class_name: str
52
- mode_field: str
53
- target_path: str
54
- presets: dict[str, str]
55
- enums: list[EnvironmentEnum]
56
- fields: list[EnvironmentFieldDump]
57
-
58
-
59
- @app.command()
60
- def unity_dispatch(
61
- project: Annotated[str, typer.Option(help="Unity project name (the 'name' field of its unity-devkit.json)")],
62
- output: Annotated[Path, typer.Option(help="Workflow file to write (or check with --check)")],
63
- build_workflow: Annotated[
64
- str,
65
- typer.Option(
66
- help="The build job's uses: value — the consumer's pinned build-unity-internal.yml reference "
67
- "(owner/repo/.github/workflows/build-unity-internal.yml@sha, or ./.github/workflows/build-unity-internal.yml inside unity-devkit)"
68
- ),
69
- ],
70
- environment_config_class: Annotated[
71
- str | None,
72
- typer.Option(help="Env class file path override (defaults to unity-devkit.json's environment_config)"),
73
- ] = None,
74
- check: Annotated[
75
- bool, typer.Option("--check", help="Compare against the existing file instead of writing; fail on drift")
76
- ] = False,
77
- ) -> None:
78
- projects = discover_projects()
79
- if project not in projects:
80
- raise SystemExit(f"Unknown project '{project}'. Valid: {', '.join(projects)}")
81
-
82
- project_path = projects[project].path
83
- env = {"ENVIRONMENT_CONFIG_CLASS": environment_config_class} if environment_config_class else None
84
- print(f"Dumping environment of {project}...")
85
- prepare_unity_project(project_path)
86
- log_path = run_unity_batchmode(
87
- project_path,
88
- f"-executeMethod {DUMP_EXECUTE_METHOD}",
89
- extra_failure_signatures=COMPILE_ERROR_SIGNATURES,
90
- env=env,
91
- )
92
- dump = extract_environment_dump(log_path)
93
- workflow = render_dispatch_workflow(dump, project=project, output=output.as_posix(), build_workflow=build_workflow)
94
-
95
- if check:
96
- if not output.is_file():
97
- raise SystemExit(f"Dispatch workflow drift: {output} does not exist — generate it")
98
- committed = output.read_text(encoding="utf-8")
99
- if committed != workflow:
100
- raise SystemExit(
101
- f"Dispatch workflow drift: {output} differs from the environment class — regenerate with "
102
- f"'{regen_command(project, output.as_posix(), build_workflow)}'"
103
- )
104
- print(f" {output} matches the environment class")
105
- return
106
-
107
- output.parent.mkdir(parents=True, exist_ok=True)
108
- output.write_text(workflow, encoding="utf-8")
109
- print(f" wrote {output}")
110
-
111
-
112
- def extract_environment_dump(log_path: Path) -> EnvironmentDump:
113
- for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
114
- candidate = line.strip()
115
- if not candidate.startswith("{"):
116
- continue
117
- try:
118
- payload = json.loads(candidate)
119
- except json.JSONDecodeError:
120
- continue
121
- if isinstance(payload, dict) and "class_name" in payload:
122
- return EnvironmentDump.model_validate(payload)
123
-
124
- raise SystemExit(f"No environment dump JSON found in the editor log at {log_path}")
125
-
126
-
127
- def render_dispatch_workflow(dump: EnvironmentDump, *, project: str, output: str, build_workflow: str) -> str:
128
- enums_by_name = {environment_enum.name: environment_enum for environment_enum in dump.enums}
129
- emitted_fields = [field for field in dump.fields if field.path != dump.mode_field]
130
- leaf_counts = Counter(field.path.rsplit(".", 1)[-1] for field in emitted_fields)
131
- input_ids: dict[str, str] = {}
132
- for field in emitted_fields:
133
- leaf = field.path.rsplit(".", 1)[-1]
134
- input_ids[field.path] = (
135
- leaf if leaf_counts[leaf] == 1 and leaf not in RESERVED_INPUT_IDS else field.path.replace(".", "-")
136
- )
137
- default_preset = next(iter(dump.presets), "")
138
- input_blocks: list[tuple[str, str | None, list[str], str | None]] = [
139
- (
140
- "environment-preset",
141
- "Preset to apply — blank leaves the workspace untouched; leave fields empty to keep the preset's values",
142
- ["type: choice", "options:", *indent_lines([*(f'- "{preset}"' for preset in ("", *dump.presets))], 2)],
143
- f'default: "{default_preset}"',
144
- ),
145
- ("development", "Development build (debug symbols)", ["type: boolean"], "default: false"),
146
- ]
147
- for field in emitted_fields:
148
- input_blocks.append(field_input_block(field, input_ids[field.path], enums_by_name))
149
-
150
- input_count = len(input_blocks)
151
- if input_count > DISPATCH_INPUT_CAP:
152
- raise SystemExit(
153
- f"Dispatch panel would carry {input_count} inputs — GitHub's workflow_dispatch cap is {DISPATCH_INPUT_CAP}. "
154
- "Shrink the env class or move configuration out of it."
155
- )
156
-
157
- transport_lines = [field_transport_line(field, input_ids[field.path]) for field in emitted_fields]
158
- header = (
159
- f"# Generated by '{regen_command(project, output, build_workflow)}' from the environment class "
160
- f"{dump.class_name} —"
161
- )
162
-
163
- workflow_call_input_blocks: list[tuple[str, str | None, list[str], str | None]] = [
164
- ("runner-labels", "JSON-encoded runs-on value", ["type: string"], "default: '\"ubuntu-latest\"'"),
165
- ("version", "Version string to stamp; empty builds unversioned", ["type: string"], "default: ''"),
166
- ]
167
-
168
- lines = [
169
- header,
170
- "# edit the class, then regenerate this workflow.",
171
- "name: Unity Dispatch Build",
172
- "",
173
- "on:",
174
- " workflow_dispatch:",
175
- " inputs:",
176
- *indent_lines([line for block in input_blocks for line in build_input_block(block)], 6),
177
- " workflow_call:",
178
- " inputs:",
179
- *indent_lines([line for block in workflow_call_input_blocks for line in build_input_block(block)], 6),
180
- "",
181
- "jobs:",
182
- " build:",
183
- f" uses: {build_workflow}",
184
- " with:",
185
- " runner-labels: ${{ inputs.runner-labels || '\"ubuntu-latest\"' }}",
186
- " version: ${{ inputs.version || '' }}",
187
- " environment-preset: ${{ inputs['environment-preset'] || '' }}",
188
- " development: ${{ inputs.development || false }}",
189
- ]
190
- if transport_lines:
191
- lines.append(" environment-fields: |-")
192
- lines.extend(indent_lines(transport_lines, 8))
193
- else:
194
- lines.append(" environment-fields: ''")
195
- lines.append(" secrets: inherit")
196
- return "\n".join(lines) + "\n"
197
-
198
-
199
- def regen_command(project: str, output: str, build_workflow: str) -> str:
200
- return f"uv run unity-dispatch --project {project} --output {output} --build-workflow {build_workflow}"
201
-
202
-
203
- def field_input_block(
204
- field: EnvironmentFieldDump, input_id: str, enums_by_name: dict[str, EnvironmentEnum]
205
- ) -> tuple[str, str | None, list[str], str | None]:
206
- if field.field_type == "Boolean":
207
- return (input_id, None, ["type: boolean"], "default: false")
208
-
209
- if field.field_type in enums_by_name:
210
- environment_enum = enums_by_name[field.field_type]
211
- if environment_enum.flags:
212
- return (
213
- input_id,
214
- f"comma-separated: {', '.join(environment_enum.values)}",
215
- ["type: string"],
216
- 'default: ""',
217
- )
218
- return (
219
- input_id,
220
- None,
221
- [
222
- "type: choice",
223
- "options:",
224
- *indent_lines([*(f'- "{value}"' for value in ("", *environment_enum.values))], 2),
225
- ],
226
- 'default: ""',
227
- )
228
-
229
- if field.field_type == "String":
230
- return (input_id, None, ["type: string"], 'default: ""')
231
-
232
- if field.field_type in NUMERIC_FIELD_TYPES:
233
- return (input_id, None, ["type: number"], None)
234
-
235
- raise SystemExit(
236
- f"Environment field '{field.path}' has type '{field.field_type}' — no dispatch input type maps to it "
237
- "(arrays and object references self-exclude from the dump; this is contract drift)"
238
- )
239
-
240
-
241
- def field_transport_line(field: EnvironmentFieldDump, input_id: str) -> str:
242
- guard = "" if field.field_type == "Boolean" else " != ''"
243
- return (
244
- "${{ inputs['"
245
- + input_id
246
- + "']"
247
- + guard
248
- + " && format('"
249
- + field.path
250
- + "={0}', inputs['"
251
- + input_id
252
- + "']) || '' }}"
253
- )
254
-
255
-
256
- def build_input_block(input_block: tuple[str, str | None, list[str], str | None]) -> list[str]:
257
- input_id, description, type_lines, default_line = input_block
258
- lines = [f"{input_id}:"]
259
- if description is not None:
260
- lines.append(f' description: "{description}"')
261
- lines.extend(f" {line}" for line in type_lines)
262
- if default_line is not None:
263
- lines.append(f" {default_line}")
264
- return lines
265
-
266
-
267
- def indent_lines(lines: list[str], spaces: int) -> list[str]:
268
- return [f"{' ' * spaces}{line}" if line else line for line in lines]
@@ -1,47 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import typer
6
- from bashrun.bash import bash
7
- from pydantic_settings import BaseSettings
8
-
9
- from ci_devkit.cache import restore, save
10
- from .license_restore import license_cache_tag
11
- from ci_devkit.setup import configure_git
12
- from ci_devkit.setup_oras import install_oras
13
-
14
-
15
- class Settings(BaseSettings):
16
- cache_registry: str
17
- github_workspace: str
18
- unity_email: str
19
- unity_password: str
20
- unity_serial: str
21
-
22
-
23
- settings = Settings.model_validate({})
24
- activate_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
25
-
26
-
27
- @activate_app.command()
28
- def activate_main(oras_push: bool = typer.Option(False, help="Push activated ULF to ORAS cache")) -> None:
29
- configure_git(settings.github_workspace)
30
- install_oras()
31
-
32
- license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
33
- license_directory.mkdir(parents=True, exist_ok=True)
34
-
35
- tag = license_cache_tag()
36
- cache_hit = False
37
- if oras_push:
38
- cache_hit = restore(settings.cache_registry, "unity-license", tag, license_directory)
39
-
40
- if not cache_hit:
41
- bash(
42
- f'unity-editor -batchmode -nographics -quit -serial "{settings.unity_serial}"'
43
- f' -username "{settings.unity_email}" -password "{settings.unity_password}" -logFile /dev/stdout'
44
- )
45
-
46
- if oras_push and not cache_hit:
47
- save(settings.cache_registry, "unity-license", tag, license_directory, ["Unity_lic.ulf"])
@@ -1,38 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import UTC, datetime
4
- import os
5
- from pathlib import Path
6
-
7
- import typer
8
- from pydantic_settings import BaseSettings
9
-
10
- from ci_devkit.cache import restore
11
-
12
-
13
- class Settings(BaseSettings):
14
- cache_registry: str
15
-
16
-
17
- tag_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
18
-
19
-
20
- @tag_app.command()
21
- def tag_main() -> None:
22
- print(license_cache_tag())
23
-
24
-
25
- def restore_license() -> None:
26
- settings = Settings.model_validate({})
27
- license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
28
- license_directory.mkdir(parents=True, exist_ok=True)
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')}"
@@ -1,52 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- from .projects import discover_projects
5
- from .player_build import LICENSE_IMAGE_MODULE, PLATFORM_CONFIGS, UNITYCI_IMAGE_REVISION, read_editor_version
6
-
7
-
8
- def build_matrix() -> None:
9
- projects = discover_projects()
10
- matrix: list[dict[str, str]] = []
11
- editor_versions: set[str] = set()
12
-
13
- for name, project in projects.items():
14
- if not project.builds:
15
- continue
16
- version = read_editor_version(project.path)
17
- editor_versions.add(version)
18
- for platform in project.builds:
19
- image_module = PLATFORM_CONFIGS[platform]["unityci_image_module"]
20
- matrix.append({
21
- "project": str(project.path.relative_to(Path.cwd())),
22
- "project-name": name,
23
- "cache-key": name.lower(),
24
- "platform": platform,
25
- "unityci-image-module": image_module,
26
- "editor-image": f"unityci/editor:{version}-{image_module}-{UNITYCI_IMAGE_REVISION}",
27
- })
28
-
29
- if not editor_versions:
30
- raise SystemExit(
31
- "No projects with builds declared — build-unity-matrix needs at least one unity-devkit.json with a non-empty 'platforms' map"
32
- )
33
- license_version = max(editor_versions)
34
- print(f"matrix={json.dumps({'include': matrix})}")
35
- print(f"license-image=unityci/editor:{license_version}-{LICENSE_IMAGE_MODULE}-{UNITYCI_IMAGE_REVISION}")
36
-
37
-
38
- def compile_check_matrix() -> None:
39
- projects = discover_projects()
40
- matrix: list[dict[str, str]] = []
41
- editor_versions: set[str] = set()
42
-
43
- for name, project in projects.items():
44
- version = read_editor_version(project.path)
45
- editor_versions.add(version)
46
- matrix.append({
47
- "project-name": name,
48
- "editor-image": f"unityci/editor:{version}-{LICENSE_IMAGE_MODULE}-{UNITYCI_IMAGE_REVISION}",
49
- })
50
-
51
- print(f"matrix={json.dumps({'include': matrix})}")
52
- print(f"license-image=unityci/editor:{max(editor_versions)}-{LICENSE_IMAGE_MODULE}-{UNITYCI_IMAGE_REVISION}")
@@ -1,225 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import pytest
5
- import yaml
6
-
7
- from unity_devkit.dispatch_workflow_generator import (
8
- EnvironmentDump,
9
- EnvironmentFieldDump,
10
- extract_environment_dump,
11
- render_dispatch_workflow,
12
- )
13
- from unity_devkit.player_build import parse_environment_fields
14
-
15
- FIXTURE_DUMP = Path(__file__).parent / "fixtures" / "environment-dump.json"
16
- PINNED_BUILD_WORKFLOW = "outernet-foundation/unity-devkit/.github/workflows/build-unity-internal.yml@cfd487e0e19b3d98046a2680a210137ec0d32832"
17
-
18
-
19
- def load_fixture_dump() -> EnvironmentDump:
20
- return EnvironmentDump.model_validate(json.loads(FIXTURE_DUMP.read_text(encoding="utf-8")))
21
-
22
-
23
- def render_fixture_workflow() -> str:
24
- return render_dispatch_workflow(
25
- load_fixture_dump(),
26
- project="PlayerBuild",
27
- output=".github/workflows/build.yml",
28
- build_workflow=PINNED_BUILD_WORKFLOW,
29
- )
30
-
31
-
32
- def test_render_modes_are_deduped_into_the_preset_choice() -> None:
33
- workflow = render_fixture_workflow()
34
-
35
- assert "configMode:" not in workflow
36
- assert 'options:\n - ""\n - "Airgapped"' in workflow
37
-
38
-
39
- def test_render_defaults_the_preset_choice_to_the_first_map_key() -> None:
40
- document = yaml.safe_load(render_fixture_workflow().replace("${{", "__EXPR_OPEN__").replace("}}", "__EXPR_CLOSE__"))
41
-
42
- trigger = document.get("on", document[True])
43
- preset = trigger["workflow_dispatch"]["inputs"]["environment-preset"]
44
- assert preset["options"] == ["", "Airgapped"]
45
- assert preset["default"] == "Airgapped"
46
-
47
-
48
- def test_render_states_the_override_law_once_on_the_preset_input() -> None:
49
- workflow = render_fixture_workflow()
50
-
51
- assert "environment field" not in workflow
52
- assert "empty keeps the preset value" not in workflow
53
- assert "checked overrides the preset value" not in workflow
54
- assert workflow.count("description:") == 5
55
-
56
-
57
- def test_render_maps_field_types_to_dispatch_inputs_without_descriptions() -> None:
58
- workflow = render_fixture_workflow()
59
-
60
- assert "\n username:\n type: string" in workflow
61
- assert "\n verboseLogging:\n type: boolean" in workflow
62
- assert "\n pollIntervalSeconds:\n type: number" in workflow
63
-
64
-
65
- def test_render_uses_leaf_names_as_input_ids_when_unique() -> None:
66
- workflow = render_fixture_workflow()
67
-
68
- assert "\n apiUrl:" in workflow
69
- assert "\n portNumber:" in workflow
70
- assert "localConfig-apiUrl" not in workflow
71
- assert "${{ inputs['apiUrl'] != '' && format('localConfig.apiUrl={0}', inputs['apiUrl']) || '' }}" in workflow
72
-
73
-
74
- def test_render_falls_back_to_dashed_paths_on_leaf_collision() -> None:
75
- dump = load_fixture_dump()
76
- dump.fields.append(EnvironmentFieldDump(path="remoteConfig.apiUrl", field_type="String"))
77
-
78
- workflow = render_dispatch_workflow(
79
- dump, project="PlayerBuild", output=".github/workflows/build.yml", build_workflow=PINNED_BUILD_WORKFLOW
80
- )
81
-
82
- assert "\n apiUrl:" not in workflow
83
- assert "\n localConfig-apiUrl:" in workflow
84
- assert "\n remoteConfig-apiUrl:" in workflow
85
-
86
-
87
- def test_render_falls_back_to_dashed_paths_on_control_id_collision() -> None:
88
- dump = load_fixture_dump()
89
- dump.fields.append(EnvironmentFieldDump(path="misc.development", field_type="Boolean"))
90
-
91
- workflow = render_dispatch_workflow(
92
- dump, project="PlayerBuild", output=".github/workflows/build.yml", build_workflow=PINNED_BUILD_WORKFLOW
93
- )
94
-
95
- assert "\n misc-development:" in workflow
96
- assert workflow.split("jobs:")[0].count("\n development:") == 1
97
-
98
-
99
- def test_render_flags_enum_is_text_not_choice() -> None:
100
- workflow = render_fixture_workflow()
101
-
102
- assert 'description: "comma-separated: None, Capture, Stream, Analyze"' in workflow
103
- assert "\n capabilities:\n description:" in workflow
104
- assert "\n capabilities:\n type: string" not in workflow
105
- assert "type: choice" in workflow
106
-
107
-
108
- def test_render_transports_booleans_only_when_checked() -> None:
109
- workflow = render_fixture_workflow()
110
-
111
- assert "${{ inputs['verboseLogging'] && format('verboseLogging={0}', inputs['verboseLogging']) || '' }}" in workflow
112
-
113
-
114
- def test_render_transports_empty_as_unset() -> None:
115
- workflow = render_fixture_workflow()
116
-
117
- assert "${{ inputs['username'] != '' && format('username={0}', inputs['username']) || '' }}" in workflow
118
-
119
-
120
- def test_render_rejects_unknown_field_type() -> None:
121
- dump = load_fixture_dump()
122
- dump.fields.append(EnvironmentFieldDump(path="weird", field_type="Byte[]"))
123
-
124
- with pytest.raises(SystemExit, match="no dispatch input type maps"):
125
- render_dispatch_workflow(
126
- dump, project="PlayerBuild", output=".github/workflows/build.yml", build_workflow=PINNED_BUILD_WORKFLOW
127
- )
128
-
129
-
130
- def test_render_enforces_the_dispatch_input_cap() -> None:
131
- dump = load_fixture_dump()
132
- dump.fields.extend(EnvironmentFieldDump(path=f"extra{index}", field_type="String") for index in range(25))
133
-
134
- with pytest.raises(SystemExit, match="cap is 25"):
135
- render_dispatch_workflow(
136
- dump, project="PlayerBuild", output=".github/workflows/build.yml", build_workflow=PINNED_BUILD_WORKFLOW
137
- )
138
-
139
-
140
- def test_render_is_deterministic() -> None:
141
- assert render_fixture_workflow() == render_fixture_workflow()
142
-
143
-
144
- def test_rendered_workflow_is_valid_yaml() -> None:
145
- document = yaml.safe_load(render_fixture_workflow().replace("${{", "__EXPR_OPEN__").replace("}}", "__EXPR_CLOSE__"))
146
-
147
- trigger = document.get("on", document[True])
148
- inputs = trigger["workflow_dispatch"]["inputs"]
149
- assert list(inputs) == [
150
- "environment-preset",
151
- "development",
152
- "username",
153
- "password",
154
- "verboseLogging",
155
- "pollIntervalSeconds",
156
- "capabilities",
157
- "apiUrl",
158
- "portNumber",
159
- ]
160
- call_inputs = trigger["workflow_call"]["inputs"]
161
- assert list(call_inputs) == ["runner-labels", "version"]
162
- assert call_inputs["runner-labels"]["type"] == "string"
163
- assert call_inputs["runner-labels"]["default"] == '"ubuntu-latest"'
164
- assert call_inputs["version"]["type"] == "string"
165
- assert call_inputs["version"]["default"] == ""
166
- assert document["jobs"]["build"]["uses"] == PINNED_BUILD_WORKFLOW
167
- assert document["jobs"]["build"]["secrets"] == "inherit"
168
- with_block = document["jobs"]["build"]["with"]
169
- assert with_block["runner-labels"] == "__EXPR_OPEN__ inputs.runner-labels || '\"ubuntu-latest\"' __EXPR_CLOSE__"
170
- assert with_block["version"] == "__EXPR_OPEN__ inputs.version || '' __EXPR_CLOSE__"
171
- assert with_block["environment-preset"] == "__EXPR_OPEN__ inputs['environment-preset'] || '' __EXPR_CLOSE__"
172
- assert with_block["development"] == "__EXPR_OPEN__ inputs.development || false __EXPR_CLOSE__"
173
-
174
-
175
- def test_render_with_block_uses_or_defaults_for_dual_trigger_support() -> None:
176
- workflow = render_fixture_workflow()
177
-
178
- assert "runner-labels: ${{ inputs.runner-labels || '\"ubuntu-latest\"' }}" in workflow
179
- assert "version: ${{ inputs.version || '' }}" in workflow
180
- assert "environment-preset: ${{ inputs['environment-preset'] || '' }}" in workflow
181
- assert "development: ${{ inputs.development || false }}" in workflow
182
-
183
-
184
- def test_render_header_embeds_the_full_regen_command() -> None:
185
- header = render_fixture_workflow().splitlines()[0]
186
-
187
- assert (
188
- f"uv run unity-dispatch --project PlayerBuild --output .github/workflows/build.yml "
189
- f"--build-workflow {PINNED_BUILD_WORKFLOW}" in header
190
- )
191
-
192
-
193
- def test_extract_from_log_text(tmp_path: Path) -> None:
194
- log_path = tmp_path / "editor.log"
195
- log_path.write_text(
196
- "Refreshing native plugins\n"
197
- '{"class_name":"Outernet.FixtureEnv","mode_field":"configMode","target_path":"t","presets":{},'
198
- '"enums":[],"fields":[]}\n'
199
- "at Outernet.EnvironmentConfig.Dump () [0x00000]\n",
200
- encoding="utf-8",
201
- )
202
-
203
- dump = extract_environment_dump(log_path)
204
-
205
- assert dump.class_name == "Outernet.FixtureEnv"
206
- assert dump.presets == {}
207
-
208
-
209
- def test_extract_fails_loudly_without_payload(tmp_path: Path) -> None:
210
- log_path = tmp_path / "editor.log"
211
- log_path.write_text("nothing to see here\n", encoding="utf-8")
212
-
213
- with pytest.raises(SystemExit, match="No environment dump JSON"):
214
- extract_environment_dump(log_path)
215
-
216
-
217
- def test_parse_environment_fields_splits_on_first_equals() -> None:
218
- fields = parse_environment_fields(["username=bot", "localConfig.apiUrl=https://x/?a=b", "", " "])
219
-
220
- assert fields == {"username": "bot", "localConfig.apiUrl": "https://x/?a=b"}
221
-
222
-
223
- def test_parse_environment_fields_rejects_bare_path() -> None:
224
- with pytest.raises(SystemExit, match="expected path=value"):
225
- parse_environment_fields(["username"])
@@ -1,61 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import pytest
5
-
6
- from unity_devkit import matrix
7
-
8
-
9
- def write_repository(tmp_path: Path, builds: list[str] | None = None) -> None:
10
- project = tmp_path / "apps" / "Tool"
11
- (project / "ProjectSettings").mkdir(parents=True)
12
- (project / "ProjectSettings" / "ProjectVersion.txt").write_text("m_EditorVersion: 6000.0.66f1\n")
13
- config: dict[str, object] = {"name": "tool"}
14
- if builds is not None:
15
- config["platforms"] = {platform: {} for platform in builds}
16
- (project / "unity-devkit.json").write_text(json.dumps(config))
17
-
18
-
19
- def test_matrix_emits_entries_from_catalog(
20
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
21
- ) -> None:
22
- write_repository(tmp_path, builds=["Linux"])
23
- monkeypatch.chdir(tmp_path)
24
-
25
- matrix.build_matrix()
26
-
27
- lines = capsys.readouterr().out.splitlines()
28
- include = json.loads(lines[0][len("matrix=") :])["include"]
29
- assert include == [
30
- {
31
- "project": str(Path("apps") / "Tool"),
32
- "project-name": "tool",
33
- "cache-key": "tool",
34
- "platform": "Linux",
35
- "unityci-image-module": "linux-il2cpp",
36
- "editor-image": "unityci/editor:6000.0.66f1-linux-il2cpp-3",
37
- }
38
- ]
39
- assert lines[1] == "license-image=unityci/editor:6000.0.66f1-linux-il2cpp-3"
40
-
41
-
42
- def test_matrix_fails_without_builds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
43
- write_repository(tmp_path)
44
- monkeypatch.chdir(tmp_path)
45
-
46
- with pytest.raises(SystemExit, match="No projects with builds declared"):
47
- matrix.build_matrix()
48
-
49
-
50
- def test_check_matrix_includes_path_only_projects(
51
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
52
- ) -> None:
53
- write_repository(tmp_path)
54
- monkeypatch.chdir(tmp_path)
55
-
56
- matrix.compile_check_matrix()
57
-
58
- lines = capsys.readouterr().out.splitlines()
59
- include = json.loads(lines[0][len("matrix=") :])["include"]
60
- assert include == [{"project-name": "tool", "editor-image": "unityci/editor:6000.0.66f1-linux-il2cpp-3"}]
61
- assert lines[1] == "license-image=unityci/editor:6000.0.66f1-linux-il2cpp-3"
File without changes