Cut CI runner minutes: duration-packed test batches, CodeQL on Linux/daily, cancellable branch packing (#4694)

Reduces GitHub Actions runner minutes without removing any OS, target
framework, test project or check. All figures come from master CI run
37520359929 (158 jobs, ~1,880 runner-minutes) and the last two weeks of
run history.

## 1. Test batches packed by duration (`get-ci-matrix.ps1`)

Previously the expander picked the **smallest** batch size that fit the
256-job limit, which meant 2 projects per job and 33 jobs per profile.
Job logs show ~45% of the test matrix (674 of ~1,516 min) went to
compiling. Each batch rebuilds the shared dependency graph: the first
project's build took 3.8–4.9 min, the second only 0.4–0.9 min.

- Projects are now packed in path order (which keeps `PubSub.*`,
`Redundancy.*`, `WotCon.*` together) up to a 30-minute weight target,
using `.github/ci-test-durations.json`. Each weight is the slowest PR
profile's test minutes, rounded up, plus 1.
- A batch never holds more projects than the job timeout can budget for:
at most 7, given 20 + n×45 ≤ 350.
- If the matrix would exceed the job limit, the target is raised
instead.
- **PR test matrix: 132 → 60 jobs** (15 per profile; macOS 33 → 15).
Each batch is estimated at ≤36 min on the slowest runner, comparable to
today's slowest jobs (37–43 min), so wall-clock should be about the
same. Expected saving is roughly 300+ runner-min per run, plus less
queueing for the scarce macOS runners.
- The weekly full scope shrinks the same way (still 13 profiles).
- The table only steers balance: a project missing from it gets
`defaultMinutes`, so it is still scheduled.
- `run-dotnet-tests.ps1` now records per-project
`buildSeconds`/`testSeconds` (batch summary and job summary). The new
`update-test-durations.ps1` refreshes the table from any run's
`dotnet-results-*` artifacts.
- `discover` output `batch_size` is renamed to `batch_target_minutes`.
Nothing consumed it.

New `CiMatrixScriptTests` cases:
- each profile runs each of its projects exactly once;
- only a lone project may exceed the target;
- PR profiles average ≥3 projects per job;
- the duration table names only existing projects.

## 2 + 3. CodeQL (`codeql-analysis.yml`)

- **No longer runs on every push to master.** Instead, a daily schedule
on master (was weekly) gives an analysis that always completes. Each
analysis takes ~3 h, and with `cancel-in-progress: true` per branch, 49
of 76 push runs in two weeks were cancelled by the next merge before
uploading anything. `master378`/`release/*`/`develop/*` keep their push
trigger. PRs are still analyzed as before.
- Only PR runs are cancelled when superseded. Push and scheduled runs
always finish.
- **Runs on `ubuntu-latest` instead of `windows-2025`.** Only net10.0 is
built, so the extracted sources are identical. The traced Windows build
alone took 70 min, against 12 min for the untraced Linux `UA.slnx`
net10.0 leg. This PR touches a `.cs` file, so its own CodeQL run is the
first measurement.

## 4. NuGet packing on feature branches (`nuget-publish.yml`)

298 of ~400 runs in two weeks were feature-branch pushes, each about 59
Windows-min across two signed builds.

- A superseded feature-branch run is now cancelled. Protected lines and
tags are never cancelled: they publish previews, and `release.yml`
promotes a run by ID. Each manual (`workflow_dispatch`) run gets its own
concurrency group, so a later push cannot cancel it.
- A new `changes` job skips packing when a feature branch, as a whole,
changes only documentation. It diffs against the merge base with master,
not the previous push, so a docs-only push that cancels a code push
cannot mask that code change. It uses the same `get-path-relevance.ps1`
rule as CI.
- Protected refs, tags, manual runs, and any branch whose merge base
cannot be resolved always pack. Checked locally for docs-only branch
(skip), code branch, protected ref, manual run and unknown commit (all
pack).

## Verification

- `CiMatrixScriptTests`, `CiPathRelevanceTests` and the new
`CiTestDurationsScriptTests` (covering `update-test-durations.ps1`): all
`*.Ci*` tests in `Opc.Ua.Tools.Tests` pass locally, 63/63.
- This PR's own CI run shows the new matrix. Compare its total
runner-minutes and longest test job against run 37520359929.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-10-07 10:00:19 +02:00
committed by GitHub
parent f32259a1a7
commit fbaabf5b28
10 changed files with 763 additions and 60 deletions
+149 -41
View File
@@ -18,20 +18,30 @@
a single target framework (see targets.props). A profile carries both the
CustomTestTarget it builds with and the framework its tests actually run on.
GitHub Actions refuses to start a workflow run whose matrices expand past 256
jobs, and that limit cannot be raised. The project fan-out is therefore
batched: each matrix entry carries several projects that the executor runs in
sequence, keeping per-project results. The batch size is the smallest one
that fits the budget, so it adapts as test projects are added instead of
silently truncating the matrix.
The project fan-out is batched: each matrix entry carries several projects
that the executor runs in sequence, keeping per-project results. Batches are
sized by measured duration rather than by project count. Every batch pays a
fixed cost of several minutes - checkout, SDK install, and the cold build of
the shared dependency graph - while a further project in the same batch only
adds its incremental build and its tests. Two projects per job spent almost
half of the pull-request matrix compiling the same assemblies over and over,
so projects are packed, in path order, into batches of about
BatchTargetMinutes each. Path order keeps related projects (PubSub.*,
Redundancy.*, WotCon.*) together, so their shared dependencies are built
once per batch.
GitHub Actions also refuses to start a workflow run whose matrices expand
past 256 jobs, and that limit cannot be raised. If the packed matrix does not
fit, the target is raised until it does, so the matrix adapts as test
projects are added instead of silently truncating.
.PARAMETER Scope
'pr' expands the workload that gates a pull request. 'full' expands the
complete workload Azure Pipelines used to run on a schedule.
.PARAMETER ExcludeMacOS
Drop the macOS test profile. The batch size is deliberately computed from the
full profile table regardless of this switch, so excluding macOS does not
Drop the macOS test profile. The batch target is deliberately computed from
the full profile table regardless of this switch, so excluding macOS does not
reshuffle the batches of the remaining profiles.
.PARAMETER OnlyProject
@@ -55,7 +65,22 @@
GitHub enforces on hosted runners. A batch whose per-project budgets add up
past this is an error rather than a clamp: clamping would let the executor
outlive the job, which is the one case that produces no annotation and no
results.
results. The packer therefore never puts more projects in one batch than
this ceiling can budget for.
.PARAMETER BatchTargetMinutes
The measured duration, in minutes, a batch is packed up to, on top of its
fixed cost. Weights come from DurationsPath and are the slowest pull-request
profile's figures, so batches on the faster runners finish sooner. A project
heavier than the target gets a batch of its own. Raised in steps of five
when the packed matrix would not fit MaxEntries.
.PARAMETER DurationsPath
JSON table of per-project weights in minutes. Defaults to
.github/ci-test-durations.json. A project missing from it is weighted with
the table's defaultMinutes, so a new project is still scheduled; the table
only steers balance, never what runs. Regenerate it with
.github/scripts/update-test-durations.ps1.
.PARAMETER RepositoryRoot
Repository root. Defaults to the root two levels above this script.
@@ -74,6 +99,8 @@ Param(
[int] $MaxEntries = 256,
[int] $ReservedEntries = 40,
[int] $MaxJobTimeoutMinutes = 350,
[int] $BatchTargetMinutes = 30,
[string] $DurationsPath = '',
[string] $RepositoryRoot = '',
[string] $ManifestPath = ''
)
@@ -86,6 +113,12 @@ if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
else {
$RepositoryRoot = (Resolve-Path -LiteralPath $RepositoryRoot).Path
}
if ([string]::IsNullOrWhiteSpace($DurationsPath)) {
$DurationsPath = Join-Path $RepositoryRoot '.github' 'ci-test-durations.json'
}
if ($BatchTargetMinutes -lt 1) {
throw "BatchTargetMinutes must be positive, not $BatchTargetMinutes."
}
# Runner images: GitHub-hosted labels.
$RunnerImages = @{
@@ -227,6 +260,56 @@ $BuildProfiles = @(
}
)
# Every batch carries this much on top of its packed weight: checkout, SDK
# install, restore, and the cold build of the dependency graph its first
# project pulls in. Run 37520359929 measured 3.8-4.9 minutes for that first
# build against 0.4-0.9 minutes for each further project in the same batch.
$BatchFixedMinutes = 6
# One combined ceiling per project, shared by that project's build and test
# invocation in run-dotnet-tests.ps1. The job timeout budgets it exactly once
# per project, so the executor must not spend it twice. Keep 45 minutes for
# mainline too: on hosted Windows, the net48 Opc.Ua.Server.Tests build plus
# its otherwise healthy test run normally takes about 25 minutes and has
# exceeded 30 under runner variance.
$PerProjectTimeoutMinutes = 45
# 20 minutes of fixed cost plus one per-project ceiling for each project has to
# stay below MaxJobTimeoutMinutes, which caps how many projects one batch takes.
$MaxProjectsPerBatch = [System.Math]::Floor(($MaxJobTimeoutMinutes - 20) / $PerProjectTimeoutMinutes)
if ($MaxProjectsPerBatch -lt 1) {
throw "MaxJobTimeoutMinutes=$MaxJobTimeoutMinutes cannot budget even one $PerProjectTimeoutMinutes-minute project."
}
if (-not (Test-Path -LiteralPath $DurationsPath -PathType Leaf)) {
throw "The per-project duration table '$DurationsPath' does not exist."
}
$durationTable = Get-Content -LiteralPath $DurationsPath -Raw | ConvertFrom-Json -AsHashtable
$DefaultProjectMinutes = [int]$durationTable.defaultMinutes
if ($DefaultProjectMinutes -lt 1) {
throw "'$DurationsPath' must declare a positive defaultMinutes."
}
$ProjectMinutes = @{}
foreach ($entry in $durationTable.projects.GetEnumerator()) {
if ([int]$entry.Value -lt 1) {
throw "'$DurationsPath' gives '$($entry.Key)' a weight of $($entry.Value); every weight must be at least one minute."
}
$ProjectMinutes[$entry.Key] = [int]$entry.Value
}
<#
.SYNOPSIS
Returns the packing weight of a project, in minutes.
#>
function Get-ProjectMinutes([string] $project)
{
$stem = [System.IO.Path]::GetFileNameWithoutExtension($project)
if ($ProjectMinutes.ContainsKey($stem)) {
return $ProjectMinutes[$stem]
}
return $DefaultProjectMinutes
}
$allTestProjects = Find-RepositoryFile '*.Tests.csproj'
$mainlineProjects = @($allTestProjects | Where-Object { $MainlineExclusions -notcontains (Split-Path $_ -Leaf) })
$durableProjects = @($allTestProjects | Where-Object { (Split-Path $_ -Leaf) -eq $DurableProject })
@@ -302,24 +385,43 @@ function Expand-BuildMatrix([string] $scope)
<#
.SYNOPSIS
Splits an ordered project list into contiguous batches of at most $size.
Packs an ordered project list into contiguous batches whose weights add up
to at most $targetMinutes, never more than $MaxProjectsPerBatch projects.
.DESCRIPTION
Contiguous rather than best-fit: path order keeps projects that share
dependencies together, and the result only depends on the list and the
table, so a run is reproducible. A project heavier than the target is
placed in a batch of its own rather than split or dropped.
#>
function Split-IntoBatches([string[]] $projects, [int] $size)
function Split-IntoBatches([string[]] $projects, [int] $targetMinutes)
{
$batches = @()
for ($index = 0; $index -lt $projects.Count; $index += $size) {
$take = [System.Math]::Min($size, $projects.Count - $index)
$batches += , @($projects[$index..($index + $take - 1)])
$current = @()
$currentMinutes = 0
foreach ($project in $projects) {
$minutes = Get-ProjectMinutes $project
if ($current.Count -gt 0 -and
($currentMinutes + $minutes -gt $targetMinutes -or $current.Count -ge $MaxProjectsPerBatch)) {
$batches += , @($current)
$current = @()
$currentMinutes = 0
}
$current += $project
$currentMinutes += $minutes
}
if ($current.Count -gt 0) {
$batches += , @($current)
}
return $batches
}
<#
.SYNOPSIS
Counts the test matrix entries a batch size produces for a scope, ignoring
which optional runners are enabled so the batch size is stable.
Counts the test matrix entries a batch target produces for a scope, ignoring
which optional runners are enabled so the target is stable.
#>
function Measure-TestEntries([string] $scope, [int] $size)
function Measure-TestEntries([string] $scope, [int] $targetMinutes)
{
$total = 0
foreach ($testProfile in $Profiles) {
@@ -330,7 +432,7 @@ function Measure-TestEntries([string] $scope, [int] $size)
if ($testProfile.ContainsKey('tier')) {
$tier = $testProfile.tier
}
$total += [System.Math]::Ceiling($projectsByTier[$tier].Count / [double]$size)
$total += @(Split-IntoBatches $projectsByTier[$tier] $targetMinutes).Count
}
return [int]$total
}
@@ -339,7 +441,7 @@ function Measure-TestEntries([string] $scope, [int] $size)
.SYNOPSIS
Expands the test matrix and the per-tuple manifest for a scope.
#>
function Expand-TestMatrix([string] $scope, [int] $size, [bool] $includeMacOS)
function Expand-TestMatrix([string] $scope, [int] $targetMinutes, [bool] $includeMacOS)
{
$entries = @()
$manifest = @()
@@ -373,15 +475,10 @@ function Expand-TestMatrix([string] $scope, [int] $size, [bool] $includeMacOS)
if ($testProfile.ContainsKey('coverage')) {
$coverage = [bool]$testProfile.coverage
}
# One combined ceiling per project, shared by that project's build and
# test invocation in run-dotnet-tests.ps1. The job timeout below budgets
# it exactly once per project, so the executor must not spend it twice.
# Keep 45 minutes for mainline too: on hosted Windows, the net48
# Opc.Ua.Server.Tests build plus its otherwise healthy test run normally
# takes about 25 minutes and has exceeded 30 under runner variance.
$perProjectTimeout = 45
# See $PerProjectTimeoutMinutes for why this is one combined ceiling.
$perProjectTimeout = $PerProjectTimeoutMinutes
$batches = Split-IntoBatches $projectsByTier[$tier] $size
$batches = @(Split-IntoBatches $projectsByTier[$tier] $targetMinutes)
for ($index = 0; $index -lt $batches.Count; $index++) {
$batch = $batches[$index]
$batchId = "$($testProfile.id)-$(($index + 1).ToString('00'))"
@@ -413,6 +510,9 @@ function Expand-TestMatrix([string] $scope, [int] $size, [bool] $includeMacOS)
projects = $batch -join ';'
perProjectTimeout = $perProjectTimeout
timeoutMinutes = $jobTimeout
# The batch's summed weight from the duration table: what the
# packer balanced, on top of the fixed per-batch cost.
packedMinutes = [int]($batch | ForEach-Object { Get-ProjectMinutes $_ } | Measure-Object -Sum).Sum
dotnet = (Get-SdkVersions $testProfile.framework) -join "`n"
}
@@ -451,30 +551,38 @@ if ($budget -lt 1) {
throw "The '$Scope' solution build matrix alone needs $($buildEntries.Count) of the $MaxEntries entries GitHub Actions allows per workflow run, leaving no room for tests."
}
$batchSize = 1
while ((Measure-TestEntries $Scope $batchSize) -gt $budget) {
$batchSize++
if ($batchSize -gt $mainlineProjects.Count) {
throw "No batch size fits the '$Scope' test workload into $budget matrix entries."
# Raising the target shrinks the matrix until it fits. Once every batch is
# already at the per-batch project cap, raising it further changes nothing, so
# that is reported rather than looped on.
$heaviestProject = (@($ProjectMinutes.Values) + $DefaultProjectMinutes | Measure-Object -Maximum).Maximum
$targetCeiling = $MaxProjectsPerBatch * $heaviestProject
$targetMinutes = $BatchTargetMinutes
while ((Measure-TestEntries $Scope $targetMinutes) -gt $budget) {
if ($targetMinutes -ge $targetCeiling) {
throw ("No batch target fits the '$Scope' test workload into $budget matrix entries; " +
"$MaxProjectsPerBatch projects per batch is the most MaxJobTimeoutMinutes allows.")
}
$targetMinutes += 5
}
$expanded = Expand-TestMatrix $Scope $batchSize (-not $ExcludeMacOS)
$expanded = Expand-TestMatrix $Scope $targetMinutes (-not $ExcludeMacOS)
$testEntries = @($expanded.entries)
$totalEntries = $testEntries.Count + $buildEntries.Count
$largestBatch = ($testEntries | ForEach-Object { @($_.projects -split ';').Count } | Measure-Object -Maximum).Maximum
Write-Host "Scope: $Scope"
Write-Host "Repository root: $RepositoryRoot"
Write-Host "Mainline projects: $($mainlineProjects.Count)"
Write-Host "Solutions: $($solutions -join ', ')"
Write-Host "Batch size: $batchSize"
Write-Host "Batch target: $targetMinutes min (+$BatchFixedMinutes fixed), at most $MaxProjectsPerBatch projects"
Write-Host "Largest batch: $largestBatch projects"
Write-Host "Test entries: $($testEntries.Count)"
Write-Host "Build entries: $($buildEntries.Count)"
Write-Host "Reserved entries: $ReservedEntries"
Write-Host "Total matrix jobs: $totalEntries (limit $MaxEntries)"
if ($totalEntries -gt $MaxEntries) {
throw "The '$Scope' workload expands to $totalEntries matrix jobs, past the $MaxEntries GitHub Actions allows per workflow run. Lower the budget so a larger batch size is chosen rather than dropping entries."
throw "The '$Scope' workload expands to $totalEntries matrix jobs, past the $MaxEntries GitHub Actions allows per workflow run. Raise BatchTargetMinutes so fewer, larger batches are chosen rather than dropping entries."
}
if (-not [string]::IsNullOrWhiteSpace($ManifestPath)) {
@@ -483,11 +591,11 @@ if (-not [string]::IsNullOrWhiteSpace($ManifestPath)) {
$null = New-Item -ItemType Directory -Path $manifestDirectory -Force
}
$document = [ordered]@{
scope = $Scope
batchSize = $batchSize
includeMacOS = (-not $ExcludeMacOS)
builds = $buildEntries
tests = @($expanded.manifest)
scope = $Scope
batchTargetMinutes = $targetMinutes
includeMacOS = (-not $ExcludeMacOS)
builds = $buildEntries
tests = @($expanded.manifest)
}
$document | ConvertTo-Json -Depth 6 | Out-File -LiteralPath $ManifestPath -Encoding utf8
Write-Host "Wrote the workload manifest to '$ManifestPath'."
@@ -499,7 +607,7 @@ $buildsJson = ConvertTo-Json -InputObject $buildEntries -Depth 5 -Compress
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) {
"tests=$testsJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"builds=$buildsJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"batch_size=$batchSize" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"batch_target_minutes=$targetMinutes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"test_entry_count=$($testEntries.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"build_entry_count=$($buildEntries.Count)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
}
+9 -3
View File
@@ -302,6 +302,10 @@ foreach ($project in $projectList) {
total = 0
passed = 0
failed = 0
# Wall-clock seconds, read by update-test-durations.ps1 to refresh
# the weights get-ci-matrix.ps1 packs batches with.
buildSeconds = 0
testSeconds = 0
}
try {
@@ -361,6 +365,7 @@ foreach ($project in $projectList) {
$projectBudget = [System.Diagnostics.Stopwatch]::StartNew()
$build = Invoke-Dotnet $buildArguments $projectBudget $PerProjectTimeoutMinutes $logPath
$record.buildSeconds = [int]$projectBudget.Elapsed.TotalSeconds
if ($build.TimedOut) {
throw ("The build exhausted the $PerProjectTimeoutMinutes-minute per-project ceiling, " +
'which the build and the test share.')
@@ -401,6 +406,7 @@ foreach ($project in $projectList) {
}
$test = Invoke-Dotnet $testArguments $projectBudget $PerProjectTimeoutMinutes $logPath
$record.testSeconds = [int]$projectBudget.Elapsed.TotalSeconds - $record.buildSeconds
$results = Measure-TestResults $projectResults
$record.total = $results.Total
@@ -457,11 +463,11 @@ $summaryPath = Join-Path $resultsRoot 'batch-summary.json'
$lines = @(
"### $CustomTestTarget / $Framework / $Configuration",
'',
'| Project | Outcome | Passed | Total | Detail |',
'| --- | --- | ---: | ---: | --- |')
'| Project | Outcome | Passed | Total | Build (s) | Test (s) | Detail |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |')
foreach ($record in $records) {
$detail = $record.reason -replace '\r?\n', ' '
$lines += "| $([System.IO.Path]::GetFileNameWithoutExtension($record.project)) | $($record.outcome) | $($record.passed) | $($record.total) | $detail |"
$lines += "| $([System.IO.Path]::GetFileNameWithoutExtension($record.project)) | $($record.outcome) | $($record.passed) | $($record.total) | $($record.buildSeconds) | $($record.testSeconds) | $detail |"
}
$lines += ''
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) {
+95
View File
@@ -0,0 +1,95 @@
<#
.SYNOPSIS
Refreshes .github/ci-test-durations.json from the results of a CI run.
.DESCRIPTION
get-ci-matrix.ps1 packs test projects into batches by the weights in
.github/ci-test-durations.json. The weights only steer balance - a stale or
missing weight never changes what runs - but batches drift out of balance as
suites grow, so refresh them from a recent run now and then:
gh run download <run-id> --pattern 'dotnet-results-*' --dir ./ci-results
./.github/scripts/update-test-durations.ps1 -ResultsPath ./ci-results -Source 'Run <run-id>'
Each project's weight is its slowest profile's test minutes, rounded up,
plus one minute for its incremental build in a batch that already built the
shared dependencies. The cold build of those dependencies is a fixed cost of
the batch, not of any project, so first-in-batch build time is ignored.
Projects the run did not execute keep their current weight.
.PARAMETER ResultsPath
Directory holding the downloaded 'dotnet-results-*' artifacts. Every
batch-summary.json below it is read.
.PARAMETER Source
Short description of the run the weights came from, recorded in the table.
.PARAMETER DurationsPath
The table to update. Defaults to .github/ci-test-durations.json.
#>
Param(
[Parameter(Mandatory = $true)]
[string] $ResultsPath,
[Parameter(Mandatory = $true)]
[string] $Source,
[string] $DurationsPath = ''
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($DurationsPath)) {
$DurationsPath = Join-Path $PSScriptRoot '..' 'ci-test-durations.json'
}
$table = Get-Content -LiteralPath $DurationsPath -Raw | ConvertFrom-Json -AsHashtable
$weights = @{}
foreach ($entry in $table.projects.GetEnumerator()) {
$weights[$entry.Key] = [int]$entry.Value
}
$summaries = @(Get-ChildItem -LiteralPath $ResultsPath -Recurse -File -Filter 'batch-summary.json')
if ($summaries.Count -eq 0) {
throw "No batch-summary.json below '$ResultsPath'. Download the run's 'dotnet-results-*' artifacts first."
}
$slowest = @{}
foreach ($summary in $summaries) {
$document = Get-Content -LiteralPath $summary.FullName -Raw | ConvertFrom-Json
foreach ($record in $document.projects) {
# A failed or timed-out project's duration says nothing about a healthy
# run, and a not-applicable one did not run at all.
if ($record.outcome -ne 'passed' -or $null -eq $record.testSeconds) {
continue
}
$stem = [System.IO.Path]::GetFileNameWithoutExtension($record.project)
if (-not $slowest.ContainsKey($stem) -or $record.testSeconds -gt $slowest[$stem]) {
$slowest[$stem] = [int]$record.testSeconds
}
}
}
if ($slowest.Count -eq 0) {
throw "The summaries below '$ResultsPath' record no passed project with a duration."
}
foreach ($entry in $slowest.GetEnumerator()) {
$weights[$entry.Key] = [int][System.Math]::Ceiling($entry.Value / 60.0) + 1
}
$keys = [System.Collections.Generic.List[string]]::new()
foreach ($key in $weights.Keys) {
$keys.Add($key)
}
$keys.Sort([System.StringComparer]::Ordinal)
$projects = [ordered]@{}
foreach ($key in $keys) {
$projects[$key] = $weights[$key]
}
[ordered]@{
source = $Source
defaultMinutes = [int]$table.defaultMinutes
projects = $projects
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $DurationsPath -Encoding utf8NoBOM
Write-Host "Updated $($slowest.Count) of $($projects.Count) weights in '$DurationsPath' from $($summaries.Count) batch summaries."