unity-devkit 0.1.0__py3-none-any.whl
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.
- unity_devkit/__init__.py +0 -0
- unity_devkit/build_unity.py +101 -0
- unity_devkit/cache.py +89 -0
- unity_devkit/ci_step.py +52 -0
- unity_devkit/compile_unity.py +46 -0
- unity_devkit/git_tags.py +24 -0
- unity_devkit/install.py +169 -0
- unity_devkit/license.py +51 -0
- unity_devkit/license_restore.py +38 -0
- unity_devkit/lock_unity.py +28 -0
- unity_devkit/matrix.py +31 -0
- unity_devkit/projects.py +53 -0
- unity_devkit/py.typed +0 -0
- unity_devkit/setup.py +127 -0
- unity_devkit/setup_oras.py +51 -0
- unity_devkit/test_unity.py +31 -0
- unity_devkit/third-party/README.md +9 -0
- unity_devkit/third-party/dotnet-install.sh +1887 -0
- unity_devkit/unity.py +173 -0
- unity_devkit-0.1.0.dist-info/METADATA +11 -0
- unity_devkit-0.1.0.dist-info/RECORD +25 -0
- unity_devkit-0.1.0.dist-info/WHEEL +4 -0
- unity_devkit-0.1.0.dist-info/entry_points.txt +9 -0
- unity_devkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- unity_devkit-0.1.0.dist-info/licenses/NOTICE +1 -0
unity_devkit/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic_settings import BaseSettings
|
|
10
|
+
|
|
11
|
+
from .cache import restore, save
|
|
12
|
+
from .ci_step import ci_step
|
|
13
|
+
from .license_restore import restore_license
|
|
14
|
+
from .setup import configure_git, install_dotnet
|
|
15
|
+
from .setup_oras import install_oras
|
|
16
|
+
from .unity import prepare_unity_project, resolve_unity_build, run_unity_batchmode
|
|
17
|
+
from .git_tags import get_latest_tag_version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(BaseSettings):
|
|
21
|
+
github_workspace: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
settings = Settings.model_validate({})
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def main(
|
|
31
|
+
project: str = typer.Option(help="Project name"),
|
|
32
|
+
project_path: Path = typer.Option(help="Path to Unity project"),
|
|
33
|
+
platform: str = typer.Option(help="Target platform"),
|
|
34
|
+
cache_key: str = typer.Option(help="Cache key prefix"),
|
|
35
|
+
run_number: int = typer.Option(0, help="CI run number"),
|
|
36
|
+
branch: str = typer.Option("dev", help="Git branch name"),
|
|
37
|
+
registry: str = typer.Option(help="OCI registry path"),
|
|
38
|
+
build_env: str = typer.Option(
|
|
39
|
+
"", help="Newline-separated KEY=VALUE pairs injected into the Unity build process environment"
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
for line in build_env.splitlines():
|
|
43
|
+
entry = line.strip()
|
|
44
|
+
if not entry:
|
|
45
|
+
continue
|
|
46
|
+
key, separator, value = entry.partition("=")
|
|
47
|
+
if not separator:
|
|
48
|
+
raise SystemExit(f"Invalid --build-env entry (expected KEY=VALUE): {entry!r}")
|
|
49
|
+
os.environ[key.strip()] = value.strip()
|
|
50
|
+
|
|
51
|
+
with ci_step("Setup"):
|
|
52
|
+
configure_git(settings.github_workspace)
|
|
53
|
+
install_dotnet("8.0")
|
|
54
|
+
install_oras()
|
|
55
|
+
restore_license()
|
|
56
|
+
|
|
57
|
+
branch_slug = branch.replace("/", "-")
|
|
58
|
+
tag = f"{cache_key}-{platform}-{branch_slug}"
|
|
59
|
+
fallback_branch = "dev"
|
|
60
|
+
fallback_tags = [f"{cache_key}-{platform}-{fallback_branch}"] if branch_slug != fallback_branch else None
|
|
61
|
+
|
|
62
|
+
with ci_step("Restore library cache"):
|
|
63
|
+
restore(registry, "unity-library", tag, Path("."), fallback_tags=fallback_tags)
|
|
64
|
+
|
|
65
|
+
with ci_step("Prepare build"):
|
|
66
|
+
project_config, build_flag, execute_method = resolve_unity_build(project, platform)
|
|
67
|
+
unity_project_path = project_config.path
|
|
68
|
+
|
|
69
|
+
with ci_step("Prepare project"):
|
|
70
|
+
prepare_unity_project(unity_project_path)
|
|
71
|
+
|
|
72
|
+
with ci_step(f"Build {unity_project_path.name} [{platform}]"):
|
|
73
|
+
tag_prefix = project_config.tag_prefix
|
|
74
|
+
if tag_prefix:
|
|
75
|
+
version = get_latest_tag_version(f"{tag_prefix}-v") or "0.0.0"
|
|
76
|
+
full_version = f"{version}-dev+{run_number}" if branch != "main" else f"{version}+{run_number}"
|
|
77
|
+
version_file = unity_project_path / ".build-version.json"
|
|
78
|
+
version_file.write_text(json.dumps({"version": full_version, "runNumber": run_number}))
|
|
79
|
+
print(f"Wrote version {full_version} (bundleVersionCode={run_number}) to {version_file}")
|
|
80
|
+
|
|
81
|
+
run_unity_batchmode(unity_project_path, f"{build_flag} -executeMethod {execute_method}", nographics=False)
|
|
82
|
+
|
|
83
|
+
with ci_step("Save library cache"):
|
|
84
|
+
# PackageCache (~1.6 GiB) is redundant with the shared UPM cache at ~/.cache/Unity/upm/
|
|
85
|
+
package_cache = project_path / "Library" / "PackageCache"
|
|
86
|
+
if package_cache.exists():
|
|
87
|
+
shutil.rmtree(package_cache)
|
|
88
|
+
|
|
89
|
+
save(registry, "unity-library", tag, Path("."), [f"{project_path}/Library/"])
|
|
90
|
+
|
|
91
|
+
with ci_step("Collect build artifacts"):
|
|
92
|
+
build_directory = project_path / "Build"
|
|
93
|
+
if build_directory.is_dir():
|
|
94
|
+
artifact_directory = Path("/tmp/unity-builds")
|
|
95
|
+
artifact_directory.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
if platform == "linux64":
|
|
97
|
+
shutil.copytree(build_directory, artifact_directory, dirs_exist_ok=True)
|
|
98
|
+
else:
|
|
99
|
+
for file in build_directory.rglob("*"):
|
|
100
|
+
if file.suffix in {".apk", ".exe"}:
|
|
101
|
+
shutil.copy2(file, artifact_directory / file.name)
|
unity_devkit/cache.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from subprocess import CalledProcessError
|
|
9
|
+
|
|
10
|
+
from bashrun import bash, bash_check, bash_pipe
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _posix(path: Path) -> str:
|
|
14
|
+
return path.as_posix() if platform.system() == "Windows" else str(path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def restore(
|
|
18
|
+
registry: str,
|
|
19
|
+
name: str,
|
|
20
|
+
tag: str,
|
|
21
|
+
target_directory: Path,
|
|
22
|
+
*,
|
|
23
|
+
required: bool = False,
|
|
24
|
+
fallback_tags: list[str] | None = None,
|
|
25
|
+
) -> bool:
|
|
26
|
+
# OCI repository names must be lowercase; GitHub repository names preserve case
|
|
27
|
+
registry = registry.lower()
|
|
28
|
+
staging = Path(tempfile.gettempdir()) / "cache"
|
|
29
|
+
|
|
30
|
+
for candidate_tag in [tag, *(fallback_tags or [])]:
|
|
31
|
+
if candidate_tag != tag:
|
|
32
|
+
print(f"Falling back to {name}:{candidate_tag}")
|
|
33
|
+
|
|
34
|
+
reference = f"{registry}/{name}:{candidate_tag}"
|
|
35
|
+
staging.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
if not bash_check(f"oras pull {reference} -o {staging}"):
|
|
38
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
archive = staging / f"{name}.tar.zst"
|
|
42
|
+
try:
|
|
43
|
+
if platform.system() == "Windows":
|
|
44
|
+
bash_pipe(f"zstd -d {_posix(archive)} --stdout", f"tar -xf - -C {_posix(target_directory)}")
|
|
45
|
+
else:
|
|
46
|
+
bash(f"tar -xf {archive} -C {target_directory}")
|
|
47
|
+
except CalledProcessError:
|
|
48
|
+
print(f"WARNING: Cache extraction failed for {name}:{candidate_tag}, trying next")
|
|
49
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
53
|
+
print(f"Cache hit: {name}:{candidate_tag}")
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
if required:
|
|
57
|
+
print(f"FATAL: Required cache missing: {registry}/{name}:{tag}")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
print(f"Cache miss: {name}")
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save(registry: str, name: str, tag: str, source_directory: Path, paths: list[str]) -> None:
|
|
65
|
+
# OCI repository names must be lowercase; GitHub repository names preserve case
|
|
66
|
+
registry = registry.lower()
|
|
67
|
+
resolved: list[str] = []
|
|
68
|
+
for pattern in paths:
|
|
69
|
+
if any(character in pattern for character in "*?["):
|
|
70
|
+
matches = sorted(source_directory.glob(pattern))
|
|
71
|
+
resolved.extend(str(match.relative_to(source_directory)) for match in matches)
|
|
72
|
+
else:
|
|
73
|
+
resolved.append(pattern)
|
|
74
|
+
|
|
75
|
+
staging = Path(tempfile.gettempdir()) / "cache"
|
|
76
|
+
staging.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
archive_name = f"{name}.tar.zst"
|
|
78
|
+
archive_path = staging / archive_name
|
|
79
|
+
|
|
80
|
+
joined = " ".join(resolved)
|
|
81
|
+
if platform.system() == "Windows":
|
|
82
|
+
bash_pipe(f"tar -cf - {joined}", f"zstd -o {_posix(archive_path)}", cwd=source_directory)
|
|
83
|
+
else:
|
|
84
|
+
bash(f"tar --zstd -cf {archive_path} {joined}", cwd=source_directory)
|
|
85
|
+
|
|
86
|
+
reference = f"{registry}/{name}:{tag}"
|
|
87
|
+
bash(f"oras push {reference} {archive_name}:application/vnd.unity-devkit.cache.v1+zstd", cwd=staging)
|
|
88
|
+
archive_path.unlink()
|
|
89
|
+
print(f"Saved cache: {reference}")
|
unity_devkit/ci_step.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
github_step_summary: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
settings = Settings.model_validate({})
|
|
15
|
+
_summary_initialized = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_duration(seconds: float) -> str:
|
|
19
|
+
if seconds < 60:
|
|
20
|
+
return f"{seconds:.1f}s"
|
|
21
|
+
minutes = int(seconds // 60)
|
|
22
|
+
remaining = seconds % 60
|
|
23
|
+
return f"{minutes}m {remaining:.0f}s"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_summary(label: str, duration: float, *, failed: bool) -> None:
|
|
27
|
+
global _summary_initialized
|
|
28
|
+
summary_path = settings.github_step_summary
|
|
29
|
+
if not summary_path:
|
|
30
|
+
return
|
|
31
|
+
with open(summary_path, "a") as file:
|
|
32
|
+
if not _summary_initialized:
|
|
33
|
+
file.write("| Step | Duration |\n|---|---|\n")
|
|
34
|
+
_summary_initialized = True
|
|
35
|
+
status = " :x:" if failed else ""
|
|
36
|
+
file.write(f"| {label}{status} | {_format_duration(duration)} |\n")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def ci_step(label: str) -> Generator[None]:
|
|
41
|
+
print(f"::group::{label}", flush=True)
|
|
42
|
+
start = time.monotonic()
|
|
43
|
+
failed = False
|
|
44
|
+
try:
|
|
45
|
+
yield
|
|
46
|
+
except BaseException:
|
|
47
|
+
failed = True
|
|
48
|
+
raise
|
|
49
|
+
finally:
|
|
50
|
+
duration = time.monotonic() - start
|
|
51
|
+
print("::endgroup::", flush=True)
|
|
52
|
+
_write_summary(label, duration, failed=failed)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from .unity import prepare_unity_project, resolve_unity_build, run_unity_batchmode
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_unity_project(project: str, build: str) -> list[Path]:
|
|
12
|
+
project_config, build_flag, execute_method = resolve_unity_build(project, build)
|
|
13
|
+
project_path = project_config.path
|
|
14
|
+
prepare_unity_project(project_path)
|
|
15
|
+
|
|
16
|
+
build_directory = project_path / "Build"
|
|
17
|
+
before = snapshot_artifacts(build_directory)
|
|
18
|
+
|
|
19
|
+
run_unity_batchmode(project_path, f"{build_flag} -executeMethod {execute_method}", nographics=False)
|
|
20
|
+
|
|
21
|
+
after = snapshot_artifacts(build_directory)
|
|
22
|
+
produced = sorted(path for path, modification_time in after.items() if before.get(path) != modification_time)
|
|
23
|
+
if not produced:
|
|
24
|
+
raise SystemExit(
|
|
25
|
+
"Unity exited 0 but no .apk/.exe under Build/ was produced or updated — "
|
|
26
|
+
"the incremental build served a stale artifact. Delete the existing "
|
|
27
|
+
"output under Build/ and the project's Library/Bee/.../build/ tree, then retry."
|
|
28
|
+
)
|
|
29
|
+
return produced
|
|
30
|
+
|
|
31
|
+
|
|
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
|
+
def snapshot_artifacts(build_directory: Path) -> dict[Path, int]:
|
|
42
|
+
if not build_directory.is_dir():
|
|
43
|
+
return {}
|
|
44
|
+
return {
|
|
45
|
+
path: path.stat().st_mtime_ns for suffix in (".apk", ".exe") for path in build_directory.rglob(f"*{suffix}")
|
|
46
|
+
}
|
unity_devkit/git_tags.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from bashrun import bash, bash_check, bash_output
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_latest_tag_version(prefix: str) -> str | None:
|
|
9
|
+
output = bash_output(f'git tag --list "{prefix}*" --sort=-v:refname').strip()
|
|
10
|
+
if not output:
|
|
11
|
+
return None
|
|
12
|
+
latest_tag = output.splitlines()[0]
|
|
13
|
+
return latest_tag[len(prefix) :]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_changes_since_tag(tag: str | None, path: Path) -> bool:
|
|
17
|
+
if tag is None:
|
|
18
|
+
return True
|
|
19
|
+
return not bash_check(f"git diff --quiet {tag} HEAD -- {path}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_and_push_tag(tag: str) -> None:
|
|
23
|
+
bash(f"git tag {tag}")
|
|
24
|
+
bash(f"git push origin {tag}")
|
unity_devkit/install.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from bashrun import bash, bash_check, bash_handoff, bash_output
|
|
10
|
+
|
|
11
|
+
from .compile_unity import build_unity_project
|
|
12
|
+
from .projects import UnityProject, load_unity_projects
|
|
13
|
+
|
|
14
|
+
INSTALLABLE_TARGETS = {"android-mobile", "magicleap", "linux64"}
|
|
15
|
+
ADB_TARGETS = {"android-mobile", "magicleap"}
|
|
16
|
+
CACHE_ROOT = Path.home() / ".unity-devkit" / "builds"
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
19
|
+
|
|
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
|
+
@app.command()
|
|
84
|
+
def main(
|
|
85
|
+
project: Annotated[str, typer.Option("--project", "-p", help="Unity project name")],
|
|
86
|
+
target: Annotated[
|
|
87
|
+
str | None,
|
|
88
|
+
typer.Option("--target", "-t", help="Device target (android-mobile, magicleap, linux64)"),
|
|
89
|
+
] = None,
|
|
90
|
+
branch: Annotated[
|
|
91
|
+
str | None,
|
|
92
|
+
typer.Option("--branch", "-b", help="Branch to find latest successful run (default: current git branch)"),
|
|
93
|
+
] = None,
|
|
94
|
+
run: Annotated[int | None, typer.Option("--run", "-r", help="Specific GitHub Actions run ID")] = None,
|
|
95
|
+
serial: Annotated[str | None, typer.Option("--serial", "-s", help="adb device serial")] = None,
|
|
96
|
+
no_grant_permissions: Annotated[
|
|
97
|
+
bool,
|
|
98
|
+
typer.Option(
|
|
99
|
+
"--no-grant-permissions",
|
|
100
|
+
help=(
|
|
101
|
+
"Skip the post-install `adb shell pm grant` calls listed under `grant_permissions` "
|
|
102
|
+
"for the project in its unity-build.json manifest. Permissions are granted by default."
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
] = False,
|
|
106
|
+
build_locally: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--build",
|
|
110
|
+
"-B",
|
|
111
|
+
help=(
|
|
112
|
+
"Compile the project locally via `compile-unity` and install the produced APK / "
|
|
113
|
+
"linux executable. Skips the GitHub Actions artifact lookup; --branch / --run are ignored."
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
] = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
projects = load_unity_projects()
|
|
119
|
+
project_name = _resolve_project(projects, project)
|
|
120
|
+
target_name = _resolve_target(projects[project_name], project_name, target)
|
|
121
|
+
|
|
122
|
+
if serial and target_name not in ADB_TARGETS:
|
|
123
|
+
print(f"Warning: --serial is ignored for target '{target_name}'")
|
|
124
|
+
|
|
125
|
+
if build_locally:
|
|
126
|
+
if branch or run:
|
|
127
|
+
print("Warning: --branch / --run are ignored when --build is set")
|
|
128
|
+
produced = build_unity_project(project_name, target_name)
|
|
129
|
+
apks = [path for path in produced if path.suffix == ".apk"]
|
|
130
|
+
executables = [path for path in produced if path.suffix == ".exe"]
|
|
131
|
+
else:
|
|
132
|
+
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)
|
|
135
|
+
print(f"Run: {run_id}")
|
|
136
|
+
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 []
|
|
140
|
+
|
|
141
|
+
if target_name in ADB_TARGETS:
|
|
142
|
+
if not apks:
|
|
143
|
+
print("No .apk found in install source")
|
|
144
|
+
raise SystemExit(1)
|
|
145
|
+
|
|
146
|
+
print(f"Installing: {apks[0].name}")
|
|
147
|
+
adb_prefix = f"adb -s {serial}" if serial else "adb"
|
|
148
|
+
package = projects[project_name].package
|
|
149
|
+
if package:
|
|
150
|
+
bash_check(f"{adb_prefix} uninstall {package}")
|
|
151
|
+
bash(f"{adb_prefix} install {apks[0]}")
|
|
152
|
+
permissions = projects[project_name].grant_permissions
|
|
153
|
+
if permissions and not no_grant_permissions:
|
|
154
|
+
if not package:
|
|
155
|
+
raise typer.BadParameter(
|
|
156
|
+
f"{project_name} has grant_permissions but no 'package' field in unity-build.json"
|
|
157
|
+
)
|
|
158
|
+
for permission in permissions:
|
|
159
|
+
print(f"Granting {permission} to {package}")
|
|
160
|
+
bash(f"{adb_prefix} shell pm grant {package} {permission}")
|
|
161
|
+
print("Done.")
|
|
162
|
+
else:
|
|
163
|
+
if not executables:
|
|
164
|
+
print("No linux executable found in install source")
|
|
165
|
+
raise SystemExit(1)
|
|
166
|
+
executable = executables[0]
|
|
167
|
+
os.chmod(executable, executable.stat().st_mode | 0o755)
|
|
168
|
+
print(f"Launching: {executable.name}")
|
|
169
|
+
bash_handoff(str(executable))
|
unity_devkit/license.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from bashrun import bash
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
from .cache import restore, save
|
|
10
|
+
from .license_restore import license_cache_tag
|
|
11
|
+
from .setup import configure_git
|
|
12
|
+
from .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
|
+
def activate(oras_push: bool) -> None:
|
|
28
|
+
configure_git(settings.github_workspace)
|
|
29
|
+
install_oras()
|
|
30
|
+
|
|
31
|
+
license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
|
|
32
|
+
license_directory.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
tag = license_cache_tag()
|
|
35
|
+
cache_hit = False
|
|
36
|
+
if oras_push:
|
|
37
|
+
cache_hit = restore(settings.cache_registry, "unity-license", tag, license_directory)
|
|
38
|
+
|
|
39
|
+
if not cache_hit:
|
|
40
|
+
bash(
|
|
41
|
+
f'unity-editor -batchmode -nographics -quit -serial "{settings.unity_serial}"'
|
|
42
|
+
f' -username "{settings.unity_email}" -password "{settings.unity_password}" -logFile /dev/stdout'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if oras_push and not cache_hit:
|
|
46
|
+
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)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic_settings import BaseSettings
|
|
9
|
+
|
|
10
|
+
from .cache import restore
|
|
11
|
+
|
|
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
|
+
class Settings(BaseSettings):
|
|
23
|
+
cache_registry: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
tag_app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@tag_app.command()
|
|
30
|
+
def tag_main() -> None:
|
|
31
|
+
print(license_cache_tag())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def restore_license() -> None:
|
|
35
|
+
settings = Settings.model_validate({})
|
|
36
|
+
license_directory = Path.home() / ".local" / "share" / "unity3d" / "Unity"
|
|
37
|
+
license_directory.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
restore(settings.cache_registry, "unity-license", license_cache_tag(), license_directory, required=True)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .projects import load_unity_projects
|
|
6
|
+
from .unity import prepare_unity_project, run_unity_batchmode
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def lock_unity(project: Annotated[str | None, typer.Option(help="Limit to a specific project.")] = None) -> None:
|
|
13
|
+
config = load_unity_projects()
|
|
14
|
+
|
|
15
|
+
if project is not None and project not in config:
|
|
16
|
+
raise typer.BadParameter(f"Unknown project '{project}'. Valid: {', '.join(config)}")
|
|
17
|
+
|
|
18
|
+
all_projects = {name: project_config.path for name, project_config in config.items()}
|
|
19
|
+
projects = {project: all_projects[project]} if project else all_projects
|
|
20
|
+
|
|
21
|
+
for name, project_path in projects.items():
|
|
22
|
+
lock_file = project_path / "Packages" / "packages-lock.json"
|
|
23
|
+
print(f"Resolving {name} ({lock_file})...")
|
|
24
|
+
|
|
25
|
+
prepare_unity_project(project_path)
|
|
26
|
+
run_unity_batchmode(project_path, strict_exit=False)
|
|
27
|
+
|
|
28
|
+
print(" Done")
|
unity_devkit/matrix.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .projects import load_unity_projects
|
|
5
|
+
from .unity import LICENSE_MODULE, PLATFORM_CONFIGS, UNITYCI_IMAGE_REVISION, read_editor_version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
projects = load_unity_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
|
+
module = PLATFORM_CONFIGS[platform]["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
|
+
"module": module,
|
|
26
|
+
"editor-image": f"unityci/editor:{version}-{module}-{UNITYCI_IMAGE_REVISION}",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
license_version = max(editor_versions)
|
|
30
|
+
print(f"matrix={json.dumps({'include': matrix})}")
|
|
31
|
+
print(f"license-image=unityci/editor:{license_version}-{LICENSE_MODULE}-{UNITYCI_IMAGE_REVISION}")
|