Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .ci/unit-tests/python/test_py2cs/test_build_plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""The build plan of Build-Repositories.ps1: the key of each repository, the build order and the restore from the build cache, with no network and no build."""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any

import pytest

REPO = Path(__file__).resolve().parents[4]
SCRIPT = REPO / ".github" / "scripts" / "Build-Repositories.ps1"
SHELL = shutil.which("pwsh") or shutil.which("powershell")
COMMITS = {"BHoM/BHoM": "a" * 40, "BHoM/BHoM_Engine": "b" * 40, "BHoM/JSONSchema_Toolkit": "c" * 40, "BHoM/CSharpSchema_Test": "d" * 40}
DEPENDENCIES = {"BHoM_Engine": ["BHoM/BHoM"], "JSONSchema_Toolkit": ["BHoM/BHoM", "BHoM/BHoM_Engine", "BHoM/Unlisted_Toolkit"], "CSharpSchema_Test": ["BHoM/BHoM"]}

pytestmark = pytest.mark.skipif(SHELL is None, reason="PowerShell is not installed")

def run(folder: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
"""Runs Build-Repositories.ps1 in the folder with the given arguments, without the GitHub output file of an enclosing workflow step.

Every test here uses it, and fails when the script fails.
"""
environment = {key: value for key, value in os.environ.items() if key != "GITHUB_OUTPUT"}
result = subprocess.run([str(SHELL), "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(SCRIPT), *arguments],
cwd=folder, env=environment, capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stdout + result.stderr
return result

def plan(folder: Path, commits: dict[str, str] | None = None, os_name: str = "Windows", tools: str = "tools 1", salt: str = "v1") -> dict[str, Any]:
"""Writes the list, the commits and the dependencies.txt files into the folder, runs the plan and returns it as read from the plan file.

The tests compare the keys of plans made from small changes to these inputs.
"""
commits = commits or COMMITS
(folder / "list.txt").write_text("\n".join(commits) + "\n", encoding="utf-8")
(folder / "commits.txt").write_text("".join(f"{repository} {commit}\n" for repository, commit in commits.items()), encoding="utf-8")
for name, dependencies in DEPENDENCIES.items():
(folder / "dependencies" / name).mkdir(parents=True, exist_ok=True)
(folder / "dependencies" / name / "dependencies.txt").write_text("\n".join(dependencies) + "\n", encoding="utf-8")
run(folder, "-List", "list.txt", "-Plan", "-PlanFile", "plan.json", "-CommitsFile", "commits.txt", "-DependenciesRoot", "dependencies",
"-OS", os_name, "-ToolsVersion", tools, "-Salt", salt)
return json.loads((folder / "plan.json").read_text(encoding="utf-8-sig"))

def keys(built: dict[str, Any]) -> dict[str, str]:
"""Returns the key of each repository of a plan, by name."""
return {repository["name"]: repository["key"] for repository in built["repositories"]}

def test_the_order_puts_each_repository_after_its_listed_dependencies(tmp_path: Path):
"""Checks that the plan orders the repositories after their dependencies, keeps only the listed dependencies, and names each entry after the repository and its key.

Protects the build, where a repository reads its dependencies' assemblies from the installation.
"""
built = plan(tmp_path)
order = [repository["name"] for repository in built["repositories"]]
assert order.index("BHoM") < order.index("BHoM_Engine") < order.index("JSONSchema_Toolkit")
assert order.index("BHoM") < order.index("CSharpSchema_Test")
toolkit = next(r for r in built["repositories"] if r["name"] == "JSONSchema_Toolkit")
assert toolkit["dependencies"] == ["BHoM", "BHoM_Engine"], "an unlisted dependency is left out"
assert all(r["entry"] == f"{r['name']}-{r['key'][:16]}" for r in built["repositories"])

def test_a_new_commit_changes_the_key_of_the_repository_and_of_what_depends_on_it_only(tmp_path: Path):
"""Checks that a new commit in one repository changes exactly its own key and the keys of the repositories that depend on it, directly or not.

Protects the point of the cache: a change rebuilds only what it can affect.
"""
(tmp_path / "before").mkdir()
before = keys(plan(tmp_path / "before"))
cases = {
"BHoM/CSharpSchema_Test": {"CSharpSchema_Test"},
"BHoM/BHoM_Engine": {"BHoM_Engine", "JSONSchema_Toolkit"},
"BHoM/BHoM": {"BHoM", "BHoM_Engine", "JSONSchema_Toolkit", "CSharpSchema_Test"},
}
for repository, expected in cases.items():
folder = tmp_path / repository.split("/")[1]
folder.mkdir()
after = keys(plan(folder, {**COMMITS, repository: "e" * 40}))
assert {name for name in before if before[name] != after[name]} == expected, repository

def test_the_salt_the_os_and_the_tools_change_every_key(tmp_path: Path):
"""Checks that a new salt, OS or build tools change the key of every repository, and that only the tools leave the state prefix unchanged.

Protects the cache from restoring what other tools built, and the refresh gate, which looks the state up on ubuntu without the Windows tools.
"""
(tmp_path / "base").mkdir()
base = plan(tmp_path / "base")
for name, change in (("salt", {"salt": "v2"}), ("os", {"os_name": "Linux"}), ("tools", {"tools": "tools 2"})):
(tmp_path / name).mkdir()
changed = plan(tmp_path / name, **change)
assert all(keys(base)[r] != keys(changed)[r] for r in keys(base)), name
assert changed["cacheKey"] != base["cacheKey"], name
assert (changed["statePrefix"] == base["statePrefix"]) == (name == "tools"), name
assert base["cacheKey"].startswith(base["statePrefix"]) and base["statePrefix"].startswith(base["cachePrefix"])
assert base["cachePrefix"] == "bhom-build-cache-v1-Windows-"

def test_only_the_msbuild_major_minor_and_the_sdk_feature_band_change_the_keys(tmp_path: Path):
"""Checks that a new patch version of MSBuild or of the .NET SDK keeps every key, while a new MSBuild minor version or SDK feature band changes them.

Protects the cache from a full rebuild whenever a re-run lands on a runner whose image is a patch ahead or behind.
"""
cases = {
"base": "msbuild 18.11.1.51303, dotnet 10.0.402",
"patch": "msbuild 18.11.2.10001, dotnet 10.0.405",
"msbuild": "msbuild 18.12.0.1, dotnet 10.0.402",
"band": "msbuild 18.11.1.51303, dotnet 10.0.300",
}
built = {}
for name, tools in cases.items():
(tmp_path / name).mkdir()
built[name] = plan(tmp_path / name, tools=tools)
assert built["base"]["tools"] == "msbuild 18.11, dotnet 10.0.4xx"
assert built["patch"]["cacheKey"] == built["base"]["cacheKey"]
assert built["msbuild"]["cacheKey"] != built["base"]["cacheKey"]
assert built["band"]["cacheKey"] != built["base"]["cacheKey"]

@pytest.mark.skipif(sys.platform != "win32", reason="the restore copies with robocopy")
def test_a_run_with_every_entry_cached_copies_them_clones_nothing_and_prunes_old_entries(tmp_path: Path):
"""Checks that when the folder holds every planned entry, the build copies them into the installation in build order, clones and builds nothing, deletes the entries of other keys, and writes commits.txt.

Protects the fast path of the PR checks and the size of the cache.
"""
built = plan(tmp_path)
cache = tmp_path / "cache"
for repository in built["repositories"]:
assemblies = cache / repository["entry"] / "Assemblies"
assemblies.mkdir(parents=True)
(assemblies / f"{repository['name']}.dll").write_text(repository["name"], encoding="utf-8")
# Shared by two entries: the one built later wins, as in a real build
(assemblies / "Shared.dll").write_text(repository["name"], encoding="utf-8")
(cache / "BHoM-0000000000000000").mkdir()
result = run(tmp_path, "-List", "list.txt", "-PlanFile", "plan.json", "-CacheFolder", "cache", "-BHoMRoot", "installation", "-ReposRoot", "repos")
installation = tmp_path / "installation"
assert all((installation / "Assemblies" / f"{name}.dll").exists() for name in keys(built))
assert (installation / "Assemblies" / "Shared.dll").read_text(encoding="utf-8") == built["repositories"][-1]["name"]
assert all((installation / folder).is_dir() for folder in ("Datasets", "Upgrades", "Settings", "Developer"))
assert sorted(p.name for p in cache.iterdir()) == sorted(r["entry"] for r in built["repositories"])
assert [p.name for p in (tmp_path / "repos").iterdir()] == ["commits.txt"], "nothing is cloned"
commits = (tmp_path / "repos" / "commits.txt").read_text(encoding="utf-8").splitlines()
assert commits == [f"{r['repository']} {r['commit']}" for r in built["repositories"]]
assert "Built: nothing" in result.stdout
79 changes: 64 additions & 15 deletions .ci/unit-tests/python/test_py2cs/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def test_all_workflows_are_valid_and_have_the_expected_jobs():
assert list(load("pr-checks.yml")["jobs"]) == ["checks", "alignment"]
assert list(load("linked-csharp-pr.yml")["jobs"]) == ["linked"], "one job, so the pull request page shows one line"
assert list(load("linked-csharp-pr-close.yml")["jobs"]) == ["close"]
assert list(load("build-cache.yml")["jobs"]) == ["gate", "refresh"]

def test_the_weekly_generation_commit_cannot_start_the_linked_pr_workflow():
"""Checks that neither linked-PR workflow has a push or schedule trigger, that they run for pull requests only on src/bhom_schema paths, and that only the close workflow reacts to a closed pull request.
Expand Down Expand Up @@ -70,11 +71,13 @@ def test_workflows_start_with_read_only_permissions_and_raise_them_per_job():

Protects the token permissions of the workflows.
"""
for name in ("linked-csharp-pr.yml", "linked-csharp-pr-close.yml", "pr-checks.yml"):
for name in ("linked-csharp-pr.yml", "linked-csharp-pr-close.yml", "pr-checks.yml", "build-cache.yml"):
assert load(name)["permissions"] == {"contents": "read"}, name

assert load("linked-csharp-pr.yml")["jobs"]["linked"]["permissions"] == {"contents": "read", "pull-requests": "write"}
assert "permissions" not in load("linked-csharp-pr-close.yml")["jobs"]["close"]
assert load("build-cache.yml")["jobs"]["gate"]["permissions"] == {"contents": "read", "actions": "read"}
assert "permissions" not in load("build-cache.yml")["jobs"]["refresh"]


def test_the_app_token_is_made_only_for_the_repositories_the_change_touches():
Expand Down Expand Up @@ -152,13 +155,14 @@ def test_every_listed_converted_repository_is_also_built():
assert all(r.lower() in built for r in lines("Converted-repositories.txt"))

def test_the_generation_and_the_checks_build_the_same_repositories():
"""Checks that the generation and PR checks workflows clone and build the repositories from the same list.
"""Checks that the generation and PR checks workflows build the repositories from the same list, the generation from its own clones and the PR checks through the build cache.

Protects align, which compares the PR with what the generator would write.
"""
for path in (WORKFLOWS / "python-schema-generation.yml", WORKFLOWS.parent / "actions" / "bhom-assemblies" / "action.yml"):
text = path.read_text(encoding="utf-8")
assert "Clone-Repositories.ps1 -List Included-repositories.txt" in text and "Build-Repositories.ps1 -List Included-repositories.txt" in text
generation = (WORKFLOWS / "python-schema-generation.yml").read_text(encoding="utf-8")
assert "Clone-Repositories.ps1 -List Included-repositories.txt" in generation and "Build-Repositories.ps1 -List Included-repositories.txt" in generation
action = (WORKFLOWS.parent / "actions" / "bhom-assemblies" / "action.yml").read_text(encoding="utf-8")
assert "Build-Repositories.ps1 -List Included-repositories.txt" in action and 'List = "Included-repositories.txt"' in action

def test_the_release_workflow_is_manual_and_only_the_publish_job_gets_an_identity_token():
"""Checks that the release workflow starts only by hand, that contents are read-only by default, and that only the TestPyPI job can ask for an identity token.
Expand Down Expand Up @@ -189,22 +193,67 @@ def test_the_release_workflow_stores_no_secret_and_nothing_is_published_in_a_dry
assert publishing and all(s["if"] == "${{ !inputs.dry_run }}" for s in publishing)


def test_the_pr_checks_build_only_when_the_cache_of_assemblies_misses():
"""Checks that the shared assemblies action caches the built BHoM assemblies under a key made from the current commits, builds only on a cache miss, and saves the cache right after the build.
def test_the_assemblies_action_plans_restores_builds_and_saves_in_that_order():
"""Checks that the shared assemblies action plans the build first, restores the build cache by its exact key then by its prefix, builds from the saved plan with the cache folder, and saves under the exact key only when an entry was added.

Protects the speed of the PR checks from a change that rebuilds every time, and the cache from becoming stale.
Protects the speed of the PR checks from a change that rebuilds every time, and the cache from being saved under a key that does not match its content.
"""
action = yaml.safe_load((WORKFLOWS.parent / "actions" / "bhom-assemblies" / "action.yml").read_text(encoding="utf-8"))
steps = action["runs"]["steps"]
by_name = {s.get("name", s.get("uses", "")): s for s in steps}
restore = by_name["Restore the built BHoM assemblies"]
assert restore["with"]["key"] == "${{ steps.commits.outputs.key }}"
for name in ("Clone the repositories", "Build the repositories in dependency order", "Save the built BHoM assemblies"):
assert by_name[name]["if"] == "steps.assemblies.outputs.cache-hit != 'true'"
assert "git ls-remote" in by_name["Find the current commit of each repository"]["run"]
order = [s.get("name", "") for s in steps]
assert order.index("Build the repositories in dependency order") < order.index("Save the built BHoM assemblies")
order = list(by_name)
assert order.index("microsoft/setup-msbuild@v2") < order.index("Plan the build"), "the MSBuild version is part of the keys"
assert order.index("Plan the build") < order.index("Restore the build cache") < order.index("Build the repositories in dependency order") < order.index("Save the build cache")
assert "-Plan -PlanFile" in by_name["Plan the build"]["run"]
restore = by_name["Restore the build cache"]
assert restore["uses"].startswith("actions/cache/restore@")
assert restore["with"] == {"path": ".build-cache", "key": "${{ steps.plan.outputs.key }}", "restore-keys": "${{ steps.plan.outputs.prefix }}"}
build = by_name["Build the repositories in dependency order"]["run"]
assert 'PlanFile = "$env:RUNNER_TEMP/build-plan.json"' in build and 'CacheFolder = ".build-cache"' in build, "the build uses the planned commits"
save = by_name["Save the build cache"]
assert save["uses"].startswith("actions/cache/save@") and save["if"] == "steps.build.outputs.added != ''"
assert save["with"] == {"path": ".build-cache", "key": "${{ steps.plan.outputs.key }}"}

def test_the_alignment_job_waits_for_the_tests_and_still_runs_when_they_fail():
"""Checks that the C# alignment job needs the build and tests job and runs unless the run is cancelled.

Protects the PR checks from building the same changed C# repository twice, and the alignment result from being skipped when a test fails.
"""
alignment = load("pr-checks.yml")["jobs"]["alignment"]
assert alignment["needs"] == "checks"
assert alignment["if"] == "${{ !cancelled() }}"

def test_the_build_cache_refresh_runs_after_the_pr_checks_and_builds_from_develop():
"""Checks that the refresh workflow starts only after the PR workflow or by hand, that its gate rejects forks and manual runs off develop and looks the current state up on develop, and that the refresh builds from develop.

Protects the develop cache, which every pull request reads, from what a pull request or a fork built.
"""
workflow = load("build-cache.yml")
assert set(workflow["on"]) == {"workflow_run", "workflow_dispatch"}
assert workflow["on"]["workflow_run"] == {"workflows": ["PR"], "types": ["completed"]}
assert load("pr-checks.yml")["name"] == "PR"
assert workflow["concurrency"]["cancel-in-progress"] is False
gate = workflow["jobs"]["gate"]
assert "github.event.workflow_run.head_repository.full_name == github.repository" in gate["if"]
assert "github.ref == 'refs/heads/develop'" in gate["if"]
assert gate["runs-on"] == "ubuntu-latest"
assert not any("bhom-assemblies" in str(s.get("uses", "")) or "actions/cache" in str(s.get("uses", "")) for s in gate["steps"]), "the gate writes no cache"
lookup = next(s for s in gate["steps"] if s.get("id") == "lookup")
assert "--ref refs/heads/develop" in lookup["run"] and "$env:STATE_PREFIX" in lookup["run"]
refresh = workflow["jobs"]["refresh"]
assert refresh["needs"] == "gate" and refresh["if"] == "needs.gate.outputs.refresh == 'true'"
checkout = next(s for s in refresh["steps"] if str(s.get("uses", "")).startswith("actions/checkout"))
assert checkout["with"]["ref"] == "develop"
assert any(s.get("uses") == "./.github/actions/bhom-assemblies" for s in refresh["steps"])

def test_the_release_and_the_generation_build_clean():
"""Checks that the release and generation workflows do not use the build cache yet.

Protects the published wheel and the weekly commit to develop from a cache that has not proved itself on the PR checks.
"""
for name in ("release.yml", "python-schema-generation.yml"):
text = (WORKFLOWS / name).read_text(encoding="utf-8")
assert "bhom-assemblies" not in text and "actions/cache" not in text and "-CacheFolder" not in text, name

def test_the_alignment_check_is_its_own_job_with_a_name_that_says_what_is_pending():
"""Checks that the C# alignment runs as its own job of pr-checks, named after the linked C# PR, using the shared assemblies action and explaining a failure in the job summary, and that the main job no longer runs align.
Expand Down
Loading
Loading