Status: implemented, except the later step 7 (generation). It replaces the "two groups" recommendation of Build-Cache-Plan.md, which stays as the background (where the time goes, what exists today).
Rebuild only the C# repositories whose own commit, or whose dependencies' commits, changed since the last build. It must work for any list in
Included-repositories.txt, not just today's four, so adding BHoM/BHoM or a toolkit needs no change to the cache.
It is adopted in steps (see "Where it is used"): pr-checks.yml first, python-schema-generation.yml once the cache has proved itself, and
release.yml keeps a clean build.
| Earlier note | Now |
|---|---|
"BHoM changes most often, so a per-repository cache helps little" |
BHoM does not change that often. When it does, everything is rebuilt (as today). In between, a change in a dependent repository rebuilds only that repository and what depends on it |
| "It cannot be tested locally without changing the BHoM installation" | Changing the installation on the development machine is acceptable, so the scripts can be tested for real. Note that some projects write to a fixed C:\ProgramData\BHoM\Assemblies (for example BHoM_Engine), so redirecting %ProgramData% would not isolate a test |
"Add a warm-up workflow on develop" |
Not as a schedule. A refresh runs on develop only when develop does not have the current state (see "Refreshing develop") |
| "The last PR that builds updates the cache and the next PR uses it" | GitHub scopes a cache to the ref that saved it: a PR can read its own caches and those of its base branch (develop), but not another PR's. A cache saved by a PR run is therefore invisible to the next PR. That is why a run on develop is needed to share it |
- A repository's build installs its assemblies into
C:\ProgramData\BHoM\Assembliesthrough a post-buildxcopy, and creates the empty foldersDatasets,Upgrades,SettingsandDevelopernext to it.BHoM_Enginealso copiesDeepCloner.dll. - Later repositories read the earlier ones' assemblies from there (
HintPath$(ProgramData)\BHoM\Assemblies\...). That is the dependency that matters. - The dependencies are listed in each repository's
dependencies.txt, oneOwner/Nameper line:BHoMhas none,BHoM_EnginelistsBHoM,JSONSchema_ToolkitlistsBHoMandBHoM_Engine.Build-Repositories.ps1already orders the build with them, among the listed repositories only. - Nothing after the build reads the C# sources in
.repos: the generator and the tests use the installed assemblies, and the generation commit message uses.repos/commits.txt. So a repository that is restored from the cache does not need to be cloned. - All assemblies on the development machine are 145 MB in 567 files. The four repositories of CI are a fraction of that, so the entries stay small. To be measured on a runner.
source(R) = sha256( "v1" | runner OS | R | commit(R) | sorted [ source(D) for D in dependencies(R) that are in the list ] )
key(R) = sha256( source(R) | tools )
commit(R)is the head ofdevelop(elsemain, elsemaster), the same choice asClone-Repositories.ps1, read withgit ls-remote.- Because
source(D)is part ofsource(R), a change in a dependency changes every key that depends on it, and nothing else. This is the table of the earlier notes: aCSharpSchema_Testcommit rebuilds one repository, aBHoM_Enginecommit rebuilds it andJSONSchema_Toolkit, aBHoMcommit rebuilds all. toolsis the hash of.github/workflows/build.ps1, the MSBuild major.minor version and the .NET SDK feature band (10.0.4xx). A change to how the repositories are built, or a new MSBuild on the runner image, then gives new keys without anybody having to remember it. Patch versions are left out: during an image rollout two attempts of the same run can land on runners a patch apart, which with exact versions rebuilt everything on a re-run. The plan logs the runner image next to the tools. The tools are kept out ofsource(R)because only the Windows runner knows them, and the refresh gate runs on ubuntu (see "Refreshingdevelop").- The leading
v1is a manual salt: bump it to throw every cache away, for example after a change to the cache scripts themselves. - Only the dependencies that are in the list count. A dependency through a repository that is not listed (A depends on an unlisted X, which depends on
a listed B) is not seen, so
key(A)ignores B.Build-Repositories.ps1has the same limitation today.
The keys are needed before any clone, so each dependencies.txt is fetched alone, at the commit just read, from
https://raw.githubusercontent.com/<repo>/<commit>/dependencies.txt. A missing file means no dependencies. This is one small request per repository,
and a hit then needs no clone at all.
The repositories are public, so no token is needed. A private repository would need a GitHub App token like the one linked-csharp-pr.yml makes,
because the workflow token can only read this repository.
A failed git ls-remote or fetch is retried a few times, then fails the run. It never falls back to a partial plan.
A folder <repo>-<key prefix>/ holding the files the repository's build added or changed under %ProgramData%\BHoM, with paths relative to that
folder. It is a plain folder, not a zip: actions/cache already compresses the whole cache, and copying a folder is faster than Expand-Archive.
- The files are found by a snapshot of the
BHoMfolder (relative path, size, last write time) before and after that repository's build. Comparing size and time is enough and fast; no hashing of 567 files. - Restoring is copying the folder into
%ProgramData%\BHoM(robocopy), in build order and overwriting, so a later repository's version of a shared file wins, as in a real build. The four empty folders are always created. - A file that two repositories copy with the same size and time (a third-party DLL keeps its package's timestamp through
xcopy) is only in the entry of the first one built. That is fine while both are in the list, but see "Risks" for removing a repository.
GitHub's actions/cache is a workflow step and cannot be called from a loop over a list read at run time, so one step per repository is not possible for
a list-driven design. Instead the entries live together in one folder, .build-cache/, which is one GitHub cache entry:
| Step | Key |
|---|---|
| Restore | exact key bhom-build-cache-v1-<OS>-<state>-<tools>, where <state> hashes every source(R) and <tools> the tools; if that misses, restore-keys prefix bhom-build-cache-v1-<OS>-, which brings the most recent folder in scope |
| Save | the exact key above, only when it was not an exact hit and an entry was added |
- An exact hit means nothing changed: nothing is built and nothing is saved.
- A prefix hit gives the folder of an earlier state. The script then finds, repository by repository, whether
<repo>-<key>/is in it. - Cache keys are immutable, so a second save of the same key fails harmlessly when two runs race.
- Before saving, entries whose key is not among this run's keys are deleted. The current heads of the repositories are the only ones a run can need, so the folder never grows beyond one entry per repository.
- Plan: read the list, the commits and the dependencies, compute every key, order the repositories (existing topological order). Print a table of
repository, commit, entry, hit or miss. A
-Planmode stops here and writes the plan to a file, which the build then reads, so the build uses the commits the key was made from. It takes the OS and the tools as parameters, so it also runs on ubuntu. - Restore the folder (exact key, then prefix).
- For each repository in order: on a hit, copy its entry. On a miss, clone it at the planned commit (
git init,git fetch --depth 1 origin <commit>,git checkout FETCH_HEAD; GitHub allows fetching a commit by its hash), snapshot, build, snapshot, copy the difference into the folder. Cloning the planned commit instead of the branch head means a push during the run cannot make the build differ from its key. A build that fails stops the run, so a failed build never becomes an entry. - Write
.repos/commits.txtfrom the plan, in the same format asClone-Repositories.ps1. - Save the folder under the exact key when something was built.
Hits skip the clone as well, so a run with every repository cached does not clone anything.
A PR run saves under the PR's own ref. For the next PR to benefit, the same state must be saved on develop. A new workflow build-cache.yml does this
only when develop does not have it yet:
| Part | Detail |
|---|---|
| Trigger | workflow_run when the PR workflow completes, plus workflow_dispatch. A workflow_run workflow runs on the default branch, so what it saves is visible to every PR |
Job 1, gate (ubuntu, a few seconds, actions: read) |
Continues only if the PR comes from this repository, not a fork (github.event.workflow_run.head_repository.full_name), or for a manual run on develop. Then runs -Plan for Windows and looks for the state prefix bhom-build-cache-v1-Windows-<state>- on develop (gh cache list --ref refs/heads/develop --key <prefix>). Continues only if it is not there, or for a manual run |
Job 2, refresh (windows) |
Runs the same action: plan, restore, build the misses, save. It builds from the trusted code on develop, never from the PR |
| Concurrency | One group, cancel-in-progress: false: the running refresh finishes, and a newer pending one replaces an older pending one. This avoids building the same state twice in parallel |
The gate asks develop directly rather than whether the PR run built something. A PR's second push can hit the PR's own cache after a refresh failed
or was cancelled, and would then never trigger a new one.
Why it rebuilds, instead of passing the PR's entries over: an entry from a PR run is code that the PR built. Putting it in the develop cache would let
one branch affect every later run, and forks could do the same. The rebuild costs one extra build per change in an upstream repository, not one per PR,
and the next PR hits.
Alternatives that were rejected:
| Alternative | Why not |
|---|---|
| A scheduled warm-up | Not event-driven: it either wastes runs or leaves a window with misses |
The PR run dispatches the refresh with gh workflow run |
Needs actions: write in the PR job, which also runs the PR's own code |
Run on push to develop |
A push to this repository does not happen when only an upstream repository changed |
| Pass the PR's entries as an artifact | The poisoning risk above |
| Gate on an artifact the PR run uploads when it built something | Misses the case above, where a PR run hits its own cache but develop was never refreshed |
The gate cannot see the tools, so after a new MSBuild on the runner image develop keeps its entry of the old tools until a repository changes
(or a manual run of build-cache.yml); in between, pull requests rebuild everything, as before this cache.
Until the generation workflow adopts the cache, nothing else refreshes develop. If no PR runs for 7 days, GitHub deletes the unused entry and the next
PR builds everything once (about 1 min 15 s), then the refresh restores it.
The composite action .github/actions/bhom-assemblies is rewritten for this. It is adopted in steps, because a wrong cache does very different harm depending on where
it is used:
| Workflow | When | Why |
|---|---|---|
pr-checks.yml, both jobs |
First change | A wrong cache gives a wrong test result on a pull request. It is seen, re-run with -NoCache, and nothing is published |
python-schema-generation.yml |
After about four weekly runs without a discrepancy | It commits straight to develop, with no pull request. The worst failure is silent: a stale entry gives Python that matches the old C#, py2cs verify and Pyright pass, and commits.txt names the new commits |
release.yml |
Not adopted: it keeps a clean build | It publishes a wheel and runs a few times a year, so saving a minute is worth nothing. A workflow_dispatch run can also read the caches of the ref it was dispatched from, so a release dispatched from a branch could use what that branch built |
In pr-checks.yml, the alignment job waits for the unit-test job (needs: checks, if: ${{ !cancelled() }}), so a miss is built once: the unit-test
job saves the cache right after the build, and the alignment job then hits it. Alignment still runs when the tests fail, and re-running only the
alignment job after the C# merge still works, because a single-job re-run does not re-run its needs. The price is an alignment result that comes
after the tests instead of beside them.
While generation still builds clean, it can also restore the cache and compare the two Assemblies folders (file list, sizes and assembly versions),
writing the differences to the step summary without using the cache. That makes the waiting period a real test.
| Situation | Today (one key) | Per repository |
|---|---|---|
| Nothing changed | restore, about 20 s | plan (a few requests) + restore + copy every entry, expected 30 to 45 s |
CSharpSchema_Test changed |
rebuild all, about 1 min 15 s | build one repository |
BHoM_Engine changed |
rebuild all | build it and JSONSchema_Toolkit |
BHoM changed |
rebuild all | rebuild all, as today |
| First run of a new PR | miss: only PR refs save the cache today, and a PR cannot read another PR's | hit, once develop has the cache |
These are estimates. The per-repository build times are not measured yet (the earlier log has no timings), so the second row onward is only as good as that measurement.
| Risk | Handling |
|---|---|
| A stale entry is used | The key contains the commit, the dependencies' keys, the OS, the build script and the MSBuild and .NET SDK versions. The v1 salt (-Salt of Build-Repositories.ps1) covers the rest |
| A build fails but its files are cached | Build-Repositories.ps1 stops at the first build that exits with an error, before the entry is complete and before the save |
| A file shared by two repositories is only in the first one's entry | Fine while both are listed, since every run installs every listed repository. After removing a repository from the list, bump the salt |
A build has side effects outside %ProgramData%\BHoM |
None known. The plan checks this by comparing the folder before and after on a real run |
| Two jobs build the same repository at once | Avoided in pr-checks.yml by needs. Elsewhere, wasted time, not a wrong result: the second save of the same key fails harmlessly |
| The cache grows or is evicted | One entry per repository, pruned on every save. Unused entries are deleted after 7 days and the repository limit is 10 GB, far above this |
git ls-remote or a dependencies.txt fetch fails |
Retried, then the run fails. It never builds from a partial plan |
| A fork spams the refresh workflow | The gate rejects runs whose head repository is not this repository, and runs on an ubuntu runner for a few seconds |
| The script is wrong and builds from stale assemblies | -NoCache ignores the cache completely, and bumping the salt resets it; both are one-line changes. Generation adopts the cache only after a comparison period, and release never does |
Steps 1 to 6 are done.
- Planning script. Add
-PlantoBuild-Repositories.ps1: commits, dependencies, keys, order, hit or miss. A-CommitsFileparameter replacesgit ls-remoteand-DependenciesRootthe fetch ofdependencies.txt, so tests need no network. - Build with the folder.
-CacheFolderforBuild-Repositories.ps1: copy on a hit, clone at the planned commit and build on a miss, snapshot, copy the difference, prune, write.repos/commits.txt.-NoCachefor the old behaviour. - Composite action. Plan, restore (exact then prefix), build, save when something was built; outputs which repositories were built.
- Use it in
pr-checks.yml, with the alignment job waiting for the unit-test job. build-cache.ymlwith the gate and refresh jobs above.- Tests and docs, listed below, then update Workflows.md and mark Build-Cache-Plan.md as superseded.
- Later, a separate change: the comparison in
python-schema-generation.yml, then the switch to the cache once it has shown no difference.
- The plan logic, with no network and no build: a test that feeds
-Plana fake commits file and fakedependencies.txtfiles and checks the keys: a change in one repository changes exactly its own key and those that depend on it, the order is topological, and the salt, the OS and the tools change every key. NUnit or pytest can startpwsh. - Restore, on a real machine: run the build twice on the development machine (the installation may be changed): the second run must copy everything and build
nothing; change one commit (or a fake commit file) and check that only that repository and its dependents are rebuilt; compare the resulting
Assembliesfolder with a clean build by file list, sizes and assembly versions (the builds are not byte-for-byte deterministic). - Workflow structure tests (the style of
test_workflows.py): the action restores before building and saves after; the alignment job needs the unit-test job and runs unless cancelled; the refresh workflow triggers only onworkflow_runand manual runs; the gate checks the head repository and looks the key up ondevelop; only the refresh job writes to thedevelopcache;release.ymldoes not use the action. - On GitHub: a first run that builds and saves, a second that restores everything, then a PR that touches one upstream repository's commit (or a fake) and rebuilds only that one, and a check that the refresh run makes the next PR hit.
The plan logic and the restore are tested in test_build_plan.py, and the workflow structure in test_workflows.py. The run on the development
machine with real builds and the runs on GitHub are still to do.
- Build time per repository from a real run (the
Building <name>...lines with timestamps), to confirm the expected gain.