diff --git a/.editorconfig b/.editorconfig
index fad7f7d..174c220 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,10 +5,11 @@
root = true
-# All files: spaces, 4-space indent, no trailing whitespace, LF-terminated final line, UTF-8.
+# All files — space indent (4), LF line endings, trim trailing whitespace, insert final newline, UTF-8.
[*]
indent_style = space
indent_size = 4
+end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
@@ -20,6 +21,10 @@ indent_size = 2
[*.{yml,yaml,json}]
indent_size = 2
+# Markdown: preserve trailing two-space hard line breaks (CommonMark
syntax).
+[*.md]
+trim_trailing_whitespace = false
+
[*.cs]
# IDE1006 — Method names must be PascalCase.
dotnet_naming_rule.methods_pascal_case.symbols = method_symbols
diff --git a/.github/actions/prepare-runner/action.yml b/.github/actions/prepare-runner/action.yml
index 272d46c..f615101 100644
--- a/.github/actions/prepare-runner/action.yml
+++ b/.github/actions/prepare-runner/action.yml
@@ -52,9 +52,8 @@ runs:
with:
dotnet-version: ${{ inputs.dotnet_version }}
- # github.action_sha is the DevOps_Toolkit commit SHA — available in composite contexts
- # without a git clone or API call. Keying the cache on this SHA ensures a new binary
- # is published whenever the action's source changes.
+ # github.action_sha is the DevOps_Toolkit SHA in composite contexts (no clone needed).
+ # Cache keyed on this SHA; any source change triggers a cache miss and fresh publish.
- name: Get DevOps_Toolkit HEAD SHA
id: sha
shell: pwsh
diff --git a/.github/actions/resolve-dependencies/action.yml b/.github/actions/resolve-dependencies/action.yml
index e5f786f..302dbc0 100644
--- a/.github/actions/resolve-dependencies/action.yml
+++ b/.github/actions/resolve-dependencies/action.yml
@@ -67,8 +67,7 @@ runs:
path: |
~/.nuget/packages
~\AppData\Local\Microsoft\MSBuild\Cache
- # dotnet_version intentionally excluded: NuGet package binaries are SDK-version-agnostic
- # for the net8.0 targets used by BHoM repos.
+ # dotnet_version excluded: NuGet binaries are SDK-version-agnostic for net8.0 targets.
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj','**/global.json','**/nuget.config') }}
restore-keys: ${{ runner.os }}-nuget-
@@ -81,14 +80,11 @@ runs:
if (Test-Path deps/_order.txt) { Remove-Item deps/_order.txt -Force }
if (Test-Path deps/_selection.txt) { Remove-Item deps/_selection.txt -Force }
- # C:\bhom-deps\ — isolated clone root, sits at the drive root so no caller
- # repo Directory.Build.props exists above it. Each dependency repo builds
- # with only its own MSBuild configuration.
+ # C:\bhom-deps\ — at the drive root so no caller Directory.Build.props propagates into dep builds.
New-Item -ItemType Directory -Force -Path "C:\bhom-deps" | Out-Null
- # Pin the dotnet SDK for all builds under C:\bhom-deps\. setup-dotnet installs
- # a SDK but dotnet selects the highest installed version at invocation time
- # unless a global.json is present. rollForward latestMinor accepts any patch.
+ # Pin SDK version via global.json: without it dotnet selects the highest installed.
+ # rollForward: latestMinor accepts any patch of the specified minor version.
$sdkVersion = "${{ inputs.dotnet_version }}"
if ($sdkVersion -match '^\d+\.\d+$') { $sdkVersion = "$sdkVersion.0" }
[ordered]@{ sdk = [ordered]@{ version = $sdkVersion; rollForward = "latestMinor" } } `
@@ -130,9 +126,8 @@ runs:
"keypart=$keypart" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- # cache-hit is true only on an exact key match. A restore-key (prefix) match
- # restores a stale cache but leaves cache-hit false, triggering a rebuild.
- # Skipped entirely when keypart is empty (no dependencies to build or cache).
+ # Exact key hit = skip build; prefix hit restores stale cache but leaves cache-hit
+ # false, still triggering a rebuild. Skipped when keypart is empty (no deps).
- name: Restore assembly cache
id: cache
if: steps.shas.outputs.keypart != ''
diff --git a/.github/actions/resolve-dependencies/scripts/Build-Dependencies.ps1 b/.github/actions/resolve-dependencies/scripts/Build-Dependencies.ps1
index b0241e3..a7d88ea 100644
--- a/.github/actions/resolve-dependencies/scripts/Build-Dependencies.ps1
+++ b/.github/actions/resolve-dependencies/scripts/Build-Dependencies.ps1
@@ -21,8 +21,7 @@ function Invoke-BHoMBuild {
throw "packages.config detected in $targetDir — legacy NuGet/MSBuild is not supported. Migrate all projects to SDK-style."
}
- # Push into the repo directory before invoking dotnet.
- # Keeps relative path resolution consistent.
+ # Push into the repo directory for consistent relative path resolution.
Push-Location $targetDir
try {
dotnet restore $Target
@@ -47,8 +46,7 @@ if (-not (Test-Path $orderOut)) {
$order = @()
}
else {
- # _order.txt contains owner/repo lines; derive repo name and path from each.
- # Filter blank lines that result when the file was written empty (no dependencies).
+ # Filter blank lines: _order.txt may be written empty when there are no dependencies.
$order = @(Get-Content $orderOut | Where-Object { $_ -match '\S' })
}
@@ -62,7 +60,11 @@ foreach ($ownerRepo in $order) {
$repoName = $ownerRepo.Split("/")[-1]
$repoPath = Join-Path $cloneRoot $repoName
if (-not (Test-Path $repoPath)) {
- Write-Host "::warning::Clone not found at $repoPath — skipping $ownerRepo"
+ # Hard-fail on missing clone: resolution recorded this repo but the clone failed
+ # (auth/network error). Continuing would yield a confusing "assembly not found" later.
+ Write-Host "::error title=Build::Clone not found at $repoPath — $ownerRepo was in the build order but was never cloned. Check for earlier auth or network errors in the dependency resolution step."
+ $overallFailures += "$repoName"
+ Write-Host "::endgroup::"
continue
}
@@ -105,9 +107,8 @@ foreach ($ownerRepo in $order) {
Write-Host "::endgroup::"
}
-# Assemblies are staged to C:\ProgramData\BHoM\Assemblies by each repo's
-# own PostBuildEvent (xcopy). No collection step needed — that directory
-# is the canonical output and is cached directly by the action.
+# Assemblies are staged to ProgramData\BHoM\Assemblies by each repo's PostBuildEvent.
+# That directory is the canonical output cached by the calling action.
$bhomAssemblies = Join-Path $env:ProgramData "BHoM\Assemblies"
$totalAssemblies = @(Get-ChildItem $bhomAssemblies -Filter *.dll -ErrorAction SilentlyContinue).Count
Write-Host "Total assemblies in ${bhomAssemblies}: $totalAssemblies"
diff --git a/.github/actions/resolve-dependencies/scripts/Resolve-DependencyGraph.ps1 b/.github/actions/resolve-dependencies/scripts/Resolve-DependencyGraph.ps1
index b22e24c..cc6b5bd 100644
--- a/.github/actions/resolve-dependencies/scripts/Resolve-DependencyGraph.ps1
+++ b/.github/actions/resolve-dependencies/scripts/Resolve-DependencyGraph.ps1
@@ -20,8 +20,7 @@ New-Item -ItemType Directory -Force -Path $cloneRoot | Out-Null
if (Test-Path $selectFile) { Remove-Item $selectFile -Force }
-# Reads non-blank, non-comment lines from a file and validates format.
-# Valid forms: owner/repo or owner/repo@branch|tag|sha (no whitespace).
+# Parses owner/repo or owner/repo@ref lines; rejects blank, comment, and malformed entries.
function Lines([string]$path) {
if (Test-Path $path) {
return Get-Content $path |
@@ -53,9 +52,8 @@ function Parse-RepoSpec([string]$spec) {
return @{ Key=$spec; Ref=$ref }
}
-# Branch preference: explicit @ref → PR_BRANCH → BASE_BRANCH → remote default
-# $Prefer is null when PR_BRANCH is unset (push/dispatch events) so the ls-remote
-# round-trip is skipped entirely rather than probing a sentinel that never exists.
+# Resolution order: explicit @ref → PR head branch → base branch → develop → remote default.
+# $Prefer is null on push/dispatch events, skipping the ls-remote probe.
$Prefer = if ([string]::IsNullOrWhiteSpace($env:PR_BRANCH)) { $null } else { $env:PR_BRANCH.Trim() }
$Fallback = if ([string]::IsNullOrWhiteSpace($env:BASE_BRANCH)) { 'develop' } else { $env:BASE_BRANCH.Trim() }
@@ -64,12 +62,13 @@ $visited = New-Object System.Collections.Generic.HashSet[string] # guards again
$nameMap = @{} # owner/repo -> folder
$pathMap = @{} # owner/repo -> path
-# Route all GitHub HTTPS clones through the token via insteadOf so the token never appears
-# in git command arguments, process listings, or git's own error messages.
-# DEP_TOKEN is masked in Actions logs; writing it into a URL rewrite rule instead of a clone
-# URL also means cloned repos' .git/config files never contain the credential.
+# Use insteadOf to inject the token at the git config level — keeps it out of command
+# arguments, process listings, and cloned repos' .git/config. Removed at end of script
+# to avoid persisting the credential on self-hosted runners between jobs.
+$tokenInsteadOfKey = $null
if (-not [string]::IsNullOrWhiteSpace($env:DEP_TOKEN)) {
- git config --global url."https://x-access-token:$($env:DEP_TOKEN)@github.com/".insteadOf "https://github.com/"
+ $tokenInsteadOfKey = "url.https://x-access-token:$($env:DEP_TOKEN)@github.com/.insteadOf"
+ git config --global $tokenInsteadOfKey "https://github.com/"
}
function Get-FolderName([string]$ownerRepo) {
@@ -121,8 +120,7 @@ function Clone-And-Checkout([string]$ownerRepo, [string]$ref) {
if ($LASTEXITCODE -ne 0) { throw "git checkout FETCH_HEAD failed for '$ownerRepo' (ref=$Fallback)" }
$selectedRef = $Fallback
} else {
- # Neither PR branch nor base branch exist on this dep repo — fall back to
- # its remote default branch (main / next / etc.)
+ # Neither PR nor base branch exists on this dep; fall back to remote default.
git fetch origin HEAD --depth 1 | Out-Null
git checkout -q FETCH_HEAD
if ($LASTEXITCODE -ne 0) { throw "git checkout FETCH_HEAD failed for '$ownerRepo' (remote default)" }
@@ -161,8 +159,7 @@ function Build-Chain([string]$ownerRepo, [bool]$includeSelf=$false, [string]$ref
Clone-And-Checkout $ownerRepo $ref | Out-Null
}
- # Guard against circular dependencies: if this repo's transitive graph has already
- # been expanded in an ancestor call, skip re-expansion to prevent infinite recursion.
+ # Already visited: skip re-expansion to prevent infinite recursion on circular deps.
if ($visited.Contains($ownerRepo)) {
if ($includeSelf -and $pathMap.ContainsKey($ownerRepo)) {
$chain.Add(@{ Key=$ownerRepo; Name=$nameMap[$ownerRepo]; Path=$pathMap[$ownerRepo] }) | Out-Null
@@ -232,8 +229,7 @@ else {
}
}
-# Additional seeds (caller mode only): appended after caller graph so caller assemblies
-# build first. Ignored when mode=seeds since that mode already accepts multiple repos.
+# Additional seeds (caller mode only): appended after the caller graph so caller assemblies build first.
if ($Mode -ne "seeds" -and -not [string]::IsNullOrWhiteSpace($AdditionalSeeds)) {
Write-Host "----- Additional seeds (appended to caller graph) -----"
@@ -264,8 +260,7 @@ foreach ($k in $phaseList) {
}
}
-# Write _order.txt — Build-Dependencies derives clone path from repo name.
-# Always create the file (even when empty) so Build-Dependencies.ps1's Get-Content never throws.
+# Always write _order.txt even when empty — Build-Dependencies.ps1 relies on it existing.
if ($merged.Count -gt 0) {
$merged | Set-Content -Path $orderOut -Encoding utf8
} else {
@@ -299,4 +294,11 @@ if (Test-Path $selectFile) {
if ($env:GITHUB_STEP_SUMMARY) {
$mdLines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
}
+}
+
+# Remove the credential rewrite — on self-hosted runners ~/.gitconfig persists between
+# jobs and would leak the short-lived token to subsequent jobs.
+if ($null -ne $tokenInsteadOfKey) {
+ git config --global --unset $tokenInsteadOfKey 2>$null
+ Write-Host "::debug::Removed git credential rewrite from global config."
}
\ No newline at end of file
diff --git a/.github/config/governance-orgs.json b/.github/config/governance-orgs.json
new file mode 100644
index 0000000..23d4251
--- /dev/null
+++ b/.github/config/governance-orgs.json
@@ -0,0 +1 @@
+["BuroHappoldEngineeringSandbox", "BHoM-migration-test-1"]
diff --git a/.github/scripts/check-format-pr.sh b/.github/scripts/check-format-pr.sh
index 8ab8893..0ef239a 100644
--- a/.github/scripts/check-format-pr.sh
+++ b/.github/scripts/check-format-pr.sh
@@ -7,8 +7,7 @@
set -euo pipefail
-# Apply canonical EditorConfig.
-# TOOLKIT_DIR is the path where DevOps_Toolkit was checked out (default: _toolkit).
+# TOOLKIT_DIR can be overridden if DevOps_Toolkit was checked out at a non-default path.
TOOLKIT_DIR="${TOOLKIT_DIR:-_toolkit}"
CANONICAL_EDITORCONFIG="$TOOLKIT_DIR/.editorconfig"
@@ -33,7 +32,7 @@ while IFS= read -r file; do
dir=$(dirname "$file")
csproj=""
while [ "$dir" != "." ] && [ "$dir" != "/" ]; do
- # shellcheck disable=SC2012
+
found=$(find "$dir" -maxdepth 1 -name "*.csproj" 2>/dev/null | head -1)
if [ -n "$found" ]; then
csproj="$found"
diff --git a/.github/scripts/distribute-editorconfig.sh b/.github/scripts/distribute-editorconfig.sh
index db7383e..21dcc71 100644
--- a/.github/scripts/distribute-editorconfig.sh
+++ b/.github/scripts/distribute-editorconfig.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Distributes config/.editorconfig to all target BHoM repos.
+# Distributes .editorconfig to all target BHoM repos.
#
# Usage: distribute-editorconfig.sh
#
@@ -12,11 +12,15 @@
# - Skips if the repo's .editorconfig already matches the canonical file.
# - Creates branch governance/update-editorconfig-YYYY-MM-DD (datestamped to
# prevent stale approvals from carrying over if the branch is re-pushed).
-# - Closes any open PRs targeting older governance/update-editorconfig-* branches
+# - Closes any open PRs opened from older governance/update-editorconfig-* branches
# before opening a fresh PR for the new branch.
# - Failures are collected and reported at the end without stopping the loop.
set -euo pipefail
+# Remove cloned repos on any exit (normal or error). Safe on ephemeral runners;
+# prevents disk accumulation on self-hosted runners.
+trap 'rm -rf targets/' EXIT
+
REPO_FILE="${1:?Usage: distribute-editorconfig.sh }"
CANONICAL_ABS="$(pwd)/.editorconfig"
BRANCH="governance/update-editorconfig-$(date +%Y-%m-%d)"
@@ -54,11 +58,21 @@ while IFS= read -r repo; do
TARGET_DIR="targets/$repo"
rm -rf "$TARGET_DIR"
- if ! gh repo clone "$ORG/$repo" "$TARGET_DIR" -- --depth=1 --branch develop --quiet; then
- echo "::error::Failed to clone $ORG/$repo"
- FAILURES+=("$repo")
- echo "::endgroup::"
- continue
+ BASE_BRANCH="develop"
+ if ! gh repo clone "$ORG/$repo" "$TARGET_DIR" -- --depth=1 --branch develop --quiet 2>/dev/null; then
+ BASE_BRANCH=$(gh repo view "$ORG/$repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)
+ if [ -z "$BASE_BRANCH" ]; then
+ echo "::error::Failed to determine default branch for $ORG/$repo"
+ FAILURES+=("$repo")
+ echo "::endgroup::"
+ continue
+ fi
+ if ! gh repo clone "$ORG/$repo" "$TARGET_DIR" -- --depth=1 --branch "$BASE_BRANCH" --quiet; then
+ echo "::error::Failed to clone $ORG/$repo (branch: $BASE_BRANCH)"
+ FAILURES+=("$repo")
+ echo "::endgroup::"
+ continue
+ fi
fi
if diff -q "$CANONICAL_ABS" "$TARGET_DIR/.editorconfig" >/dev/null 2>&1; then
@@ -88,7 +102,7 @@ while IFS= read -r repo; do
# --force is safe: this branch is exclusively owned by this workflow.
git push origin "$BRANCH" --force
- # Close any stale PRs from older datestamped branches.
+ # Close PRs opened from earlier governance branches so reviewers see only one open PR per repo.
gh pr list \
--repo "$ORG/$repo" \
--state open \
@@ -96,8 +110,7 @@ while IFS= read -r repo; do
--jq ".[] | select(.headRefName | startswith(\"${BRANCH_PREFIX}\")) | select(.headRefName != \"${BRANCH}\") | .number" \
| xargs -r -I{} gh pr close {} --repo "$ORG/$repo" --comment "Superseded by a newer sync run."
- # Open a PR if one doesn't already exist — a new commit on the existing
- # branch is sufficient to update an open PR; no need to recreate it.
+ # A new commit on an already-open PR branch updates it automatically; only create if none exists.
if gh pr list \
--repo "$ORG/$repo" \
--state open \
@@ -109,9 +122,9 @@ while IFS= read -r repo; do
PR_URL=$(gh pr create \
--repo "$ORG/$repo" \
--head "$BRANCH" \
- --base develop \
+ --base "$BASE_BRANCH" \
--title "chore: update .editorconfig" \
- --body "Automated update of \`.editorconfig\` from [DevOps_Toolkit](https://github.com/${ORG}/DevOps_Toolkit/blob/main/config/.editorconfig).
+ --body "Automated update of \`.editorconfig\` from [DevOps_Toolkit](https://github.com/${ORG}/DevOps_Toolkit/blob/main/.editorconfig).
Formatting rules are managed centrally in DevOps_Toolkit. This PR was opened automatically.
@@ -135,7 +148,7 @@ done < "$REPO_FILE"
# ── Step summary ─────────────────────────────────────────────────────────────
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
- echo "### .editorconfig distribution"
+ echo "### .editorconfig distribution — ${ORG}"
echo ""
echo "| | Count |"
echo "|---|---|"
@@ -168,3 +181,4 @@ if [ "$DRY_RUN" = "true" ]; then
else
echo "::notice::Distribution complete. Updated: $UPDATED Skipped: $SKIPPED"
fi
+# targets/ cleanup is handled by the EXIT trap.
diff --git a/.github/scripts/sync-codeowners.sh b/.github/scripts/sync-codeowners.sh
deleted file mode 100644
index b6c8ab9..0000000
--- a/.github/scripts/sync-codeowners.sh
+++ /dev/null
@@ -1,329 +0,0 @@
-#!/usr/bin/env bash
-# Syncs .github/CODEOWNERS in target BHoM repos from config/repo-ownership.yml.
-#
-# Usage: sync-codeowners.sh
-#
-# Environment variables (set by the calling workflow):
-# GH_TOKEN — installation token for the DevOps GitHub App
-# ORG — GitHub organisation login
-# DRY_RUN — 'true' to log intended changes without opening PRs
-# PLATFORM_TEAM — slug of the platform team (default: platform)
-#
-# Ownership source of truth: config/repo-ownership.yml in DevOps_Toolkit.
-# Each repo maps to exactly one discipline team slug. GitHub team assignments
-# are cross-checked for write (or higher) access; a warning is emitted if the
-# named team lacks it — CODEOWNERS is only effective in GitHub when the owning
-# team has at least write access to the repo.
-#
-# Platform-owned repos (discipline team = platform team) generate a single-line
-# CODEOWNERS giving the platform team full ownership of the repository.
-#
-# Behaviour per repo:
-# - Resolves the discipline team from config/repo-ownership.yml.
-# - Skips with a warning if no entry exists in the map (counted separately from failures).
-# - Cross-checks that the team has write (or higher) access in GitHub (warning only).
-# - Generates the expected CODEOWNERS content.
-# - In dry-run mode, prints the full expected CODEOWNERS content for review.
-# - Skips if the repo's CODEOWNERS already matches the expected content.
-# - Creates branch governance/sync-codeowners-YYYY-MM-DD (datestamped to
-# prevent stale approvals from carrying over across runs).
-# - Closes any open PRs targeting older governance/sync-codeowners-* branches
-# before opening a fresh PR for the new branch.
-# - Failures are collected and reported at the end without stopping the loop.
-set -euo pipefail
-
-REPO_FILE="${1:?Usage: sync-codeowners.sh }"
-BRANCH="governance/sync-codeowners-$(date +%Y-%m-%d)"
-BRANCH_PREFIX="governance/sync-codeowners-"
-DRY_RUN="${DRY_RUN:-false}"
-ORG="${ORG:?ORG must be set}"
-PLATFORM_TEAM="${PLATFORM_TEAM:-platform}"
-CODEOWNERS_PATH=".github/CODEOWNERS"
-OWNERSHIP_FILE="$(pwd)/config/repo-ownership.yml"
-
-SKIPPED=0
-UPDATED=0
-DRY_RUN_COUNT=0
-FAILURES=() # clone/push/API errors
-MISSING=() # repos in target list with no entry in repo-ownership.yml
-WRITE_WARNINGS=() # repos where the discipline team lacks write access
-# Associative array: repo → discipline team slug; loaded from OWNERSHIP_FILE.
-# Initialised with =() so bash considers it 'set' under set -u.
-declare -A REPO_OWNER=()
-
-if [ ! -f "$REPO_FILE" ]; then
- echo "::error::Repo list file not found: $REPO_FILE"
- exit 1
-fi
-
-git config --global user.name "bhom-devops[bot]"
-git config --global user.email "bhom-devops[bot]@users.noreply.github.com"
-git config --global credential.helper \
- '!f() { echo "username=x-access-token"; echo "password=${GH_TOKEN}"; }; f'
-
-# ── Helpers ───────────────────────────────────────────────────────────────────
-
-# Loads REPO_OWNER from OWNERSHIP_FILE using yq to parse the team-grouped YAML.
-# Fails immediately if a repo appears under more than one team (duplicate detection).
-load_ownership_map() {
- echo "Loading ownership map from ${OWNERSHIP_FILE}..."
- while IFS=$'\t' read -r team repo; do
- [ -z "$repo" ] && continue
- if [[ -v REPO_OWNER["$repo"] ]]; then
- echo "::error::Duplicate entry in config/repo-ownership.yml — '$repo' is listed under both '${REPO_OWNER[$repo]}' and '$team'. Each repo must appear exactly once."
- exit 1
- fi
- REPO_OWNER["$repo"]="$team"
- done < <(yq eval '.teams | to_entries | .[] | .key as $team | .value[] | [$team, .] | @tsv' "$OWNERSHIP_FILE")
- echo "Loaded ${#REPO_OWNER[@]} repo ownership entries."
-}
-
-# Validates that every team slug in the ownership map exists as a GitHub org team.
-# Fails immediately if any slug is invalid — a typo produces an unenforceable CODEOWNERS entry.
-validate_team_slugs() {
- echo "Validating team slugs against GitHub org..."
- local invalid=0
- declare -A seen=()
- for slug in "${REPO_OWNER[@]}"; do
- seen["$slug"]=1
- done
- for slug in "${!seen[@]}"; do
- if ! gh api "orgs/${ORG}/teams/${slug}" > /dev/null 2>&1; then
- echo "::error::Team '${slug}' is listed in config/repo-ownership.yml but does not exist in the GitHub org. Fix the slug before re-running."
- invalid=$((invalid + 1))
- fi
- done
- if [ "$invalid" -gt 0 ]; then
- echo "::error::${invalid} invalid team slug(s) found in config/repo-ownership.yml. Correct them before re-running."
- exit 1
- fi
- echo "All team slugs verified."
-}
-
-# Returns 0 (success) if $1 team has write (push) or higher access to $2 repo.
-# Uses GET /orgs/{org}/teams/{slug}/repos/{owner}/{repo} — covered by Members: Read.
-check_team_write_access() {
- local team="$1" repo="$2"
- local has_write
- has_write=$(gh api "orgs/${ORG}/teams/${team}/repos/${ORG}/${repo}" \
- --jq '.permissions.push' 2>/dev/null || echo "false")
- [ "$has_write" = "true" ]
-}
-
-# Generates the expected CODEOWNERS content for a repo.
-# Platform-owned repos get a single-line file; all others get the two-tier layout.
-generate_codeowners() {
- local repo="$1"
- local team="$2"
-
- echo "# CODEOWNERS — managed by sync-codeowners workflow, do not edit manually."
- echo "# To change ownership, update config/repo-ownership.yml in DevOps_Toolkit and re-run the workflow."
- echo ""
- if [ "$team" = "$PLATFORM_TEAM" ]; then
- echo "# Platform team owns this repository in its entirety."
- echo "* @${ORG}/${PLATFORM_TEAM}"
- else
- echo "# Discipline team owns all source code."
- echo "* @${ORG}/${team}"
- echo ""
- echo "# Platform team owns all governance, CI configuration, and centrally managed files."
- echo "/.github/ @${ORG}/${PLATFORM_TEAM}"
- echo "/.editorconfig @${ORG}/${PLATFORM_TEAM}"
- echo "/.gitattributes @${ORG}/${PLATFORM_TEAM}"
- echo "/Directory.Build.props @${ORG}/${PLATFORM_TEAM}"
- echo "/LICENSE @${ORG}/${PLATFORM_TEAM}"
- fi
-}
-
-mkdir -p targets
-
-# ── Preflight ─────────────────────────────────────────────────────────────────
-if [ ! -f "$OWNERSHIP_FILE" ]; then
- echo "::error::Ownership map not found at ${OWNERSHIP_FILE}."
- echo "::error::Create config/repo-ownership.yml in DevOps_Toolkit before running this workflow."
- exit 1
-fi
-
-# Verify the App token can query team→repo access (Members: Read).
-# Required by check_team_write_access (GET /orgs/{org}/teams/{slug}/repos/{owner}/{repo}).
-echo "Checking App token has permission to query org teams..."
-if ! gh api "orgs/${ORG}/teams" --paginate > /dev/null 2>&1; then
- echo "::error::The App token cannot list org teams (GET /orgs/{org}/teams)."
- echo "::error::Confirm 'Organisation > Members: Read' is granted to the GitHub App installation."
- exit 1
-fi
-echo "Permission check passed."
-
-load_ownership_map
-validate_team_slugs
-
-# ── Main loop ─────────────────────────────────────────────────────────────────
-
-while IFS= read -r repo; do
- [ -z "$repo" ] && continue
- echo "::group::$ORG/$repo"
-
- # Resolve discipline team from the ownership map.
- if [[ ! -v REPO_OWNER["$repo"] ]]; then
- echo "::warning::$repo has no entry in config/repo-ownership.yml — skipped. Add the discipline team to the map and re-run."
- MISSING+=("$repo")
- echo "::endgroup::"
- continue
- fi
-
- DISCIPLINE_TEAM="${REPO_OWNER[$repo]}"
-
- # Cross-check: the named team must have write (or higher) access, otherwise
- # GitHub silently ignores the team in CODEOWNERS even if the slug is correct.
- if ! check_team_write_access "$DISCIPLINE_TEAM" "$repo"; then
- echo "::warning::$repo — '@${ORG}/${DISCIPLINE_TEAM}' does not have write (or higher) access. CODEOWNERS will not be enforced by GitHub until the team is assigned with at least write access."
- WRITE_WARNINGS+=("$repo (@${ORG}/${DISCIPLINE_TEAM})")
- fi
-
- EXPECTED="$(generate_codeowners "$repo" "$DISCIPLINE_TEAM")"
-
- # Fetch the current CODEOWNERS content (empty string if file absent).
- CURRENT="$(gh api "repos/${ORG}/${repo}/contents/${CODEOWNERS_PATH}" \
- --jq '.content' 2>/dev/null \
- | base64 --decode 2>/dev/null \
- || true)"
-
- if [ "$(echo "$CURRENT" | tr -d '[:space:]')" = "$(echo "$EXPECTED" | tr -d '[:space:]')" ]; then
- echo "::notice::$repo already up to date — skipped."
- SKIPPED=$((SKIPPED + 1))
- echo "::endgroup::"
- continue
- fi
-
- if [ "$DRY_RUN" = "true" ]; then
- echo "::notice::[dry run] $repo would be updated. Discipline team: ${DISCIPLINE_TEAM}"
- echo "--- Expected CODEOWNERS for $repo ---"
- echo "$EXPECTED"
- echo "--- End ---"
- DRY_RUN_COUNT=$((DRY_RUN_COUNT + 1))
- echo "::endgroup::"
- continue
- fi
-
- TARGET_DIR="targets/$repo"
- rm -rf "$TARGET_DIR"
-
- (
- set -euo pipefail
-
- if ! gh repo clone "$ORG/$repo" "$TARGET_DIR" -- --depth=1 --branch develop --quiet; then
- echo "::error::Failed to clone $ORG/$repo"
- exit 1
- fi
-
- cd "$TARGET_DIR"
-
- git checkout -b "$BRANCH"
- mkdir -p .github
- echo "$EXPECTED" > "$CODEOWNERS_PATH"
- git add "$CODEOWNERS_PATH"
- git commit -m "chore: sync CODEOWNERS from team assignments"
-
- # --force is safe: this branch is exclusively owned by this workflow.
- # Force-pushing also invalidates stale approvals on same-day re-runs
- # where team assignments may have changed since the last push.
- git push origin "$BRANCH" --force
-
- # Close any stale PRs from older datestamped branches.
- gh pr list \
- --repo "$ORG/$repo" \
- --state open \
- --json number,headRefName \
- --jq ".[] | select(.headRefName | startswith(\"${BRANCH_PREFIX}\")) | select(.headRefName != \"${BRANCH}\") | .number" \
- | xargs -r -I{} gh pr close {} --repo "$ORG/$repo" --comment "Superseded by a newer sync run."
-
- # Open a PR if one doesn't already exist — a new commit on the existing
- # branch is sufficient to update an open PR; no need to recreate it.
- if gh pr list \
- --repo "$ORG/$repo" \
- --state open \
- --head "$BRANCH" \
- --json number \
- --jq '.[0]' | grep -q .; then
- echo "::notice::Commit pushed to existing PR."
- else
- PR_URL=$(gh pr create \
- --repo "$ORG/$repo" \
- --head "$BRANCH" \
- --base develop \
- --title "chore: sync CODEOWNERS from team assignments" \
- --body "Automated update of \`.github/CODEOWNERS\` to reflect current GitHub team assignments.
-
-**Discipline team:** \`@${ORG}/${DISCIPLINE_TEAM}\`
-
-Ownership is declared in [\`config/repo-ownership.yml\`](https://github.com/${ORG}/DevOps_Toolkit/blob/main/config/repo-ownership.yml) in DevOps_Toolkit. This PR was opened by the [Sync CODEOWNERS](https://github.com/${ORG}/DevOps_Toolkit/actions/workflows/governance-sync-codeowners.yml) workflow. Review the diff and merge once confirmed.
-
-> [!NOTE]
-> The \`.github/\` folder is owned by \`@${ORG}/${PLATFORM_TEAM}\` — platform team approval is required to merge.")
- echo "::notice::PR opened: $PR_URL"
- fi
- ) || {
- echo "::error::Failed to update $repo"
- FAILURES+=("$repo")
- echo "::endgroup::"
- continue
- }
-
- UPDATED=$((UPDATED + 1))
- echo "::endgroup::"
-done < "$REPO_FILE"
-
-# ── Step summary ──────────────────────────────────────────────────────────────
-if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
- {
- echo "### CODEOWNERS sync"
- echo ""
- echo "| | Count |"
- echo "|---|---|"
- if [ "$DRY_RUN" = "true" ]; then
- echo "| Would be updated (dry run) | $DRY_RUN_COUNT |"
- echo "| Already up to date | $SKIPPED |"
- echo "| Missing from ownership map | ${#MISSING[@]} |"
- else
- echo "| PRs opened | $UPDATED |"
- echo "| Already up to date | $SKIPPED |"
- echo "| Missing from ownership map | ${#MISSING[@]} |"
- echo "| Failed | ${#FAILURES[@]} |"
- fi
-
- if [ "${#MISSING[@]}" -gt 0 ]; then
- echo ""
- echo "**Repos missing from \`config/repo-ownership.yml\`** — add a discipline team and re-run:"
- for r in "${MISSING[@]}"; do
- echo "- \`$r\`"
- done
- fi
-
- if [ "${#WRITE_WARNINGS[@]}" -gt 0 ]; then
- echo ""
- echo "**Write-access warnings** — CODEOWNERS will not be enforced until resolved:"
- for r in "${WRITE_WARNINGS[@]}"; do
- echo "- \`$r\`"
- done
- fi
-
- if [ "${#FAILURES[@]}" -gt 0 ]; then
- echo ""
- echo "**Failed repos:**"
- for r in "${FAILURES[@]}"; do
- echo "- \`$r\`"
- done
- fi
- } >> "$GITHUB_STEP_SUMMARY"
-fi
-
-if [ "${#FAILURES[@]}" -gt 0 ]; then
- echo "::error::Sync failed for ${#FAILURES[@]} repo(s): ${FAILURES[*]}"
- exit 1
-fi
-
-if [ "$DRY_RUN" = "true" ]; then
- echo "::notice::Sync complete (dry run). Would update: $DRY_RUN_COUNT Already up to date: $SKIPPED Missing from map: ${#MISSING[@]}"
-else
- echo "::notice::Sync complete. PRs opened: $UPDATED Skipped: $SKIPPED Missing from map: ${#MISSING[@]}"
-fi
diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index 848a825..914515b 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -6,7 +6,6 @@ on:
dotnet_version:
type: string
required: true
- # SDK-style: dotnet CLI. Legacy Framework: MSBuild + NuGet from resolve-dependencies.
configuration:
type: string
required: true
@@ -117,10 +116,8 @@ jobs:
exit $LASTEXITCODE
}
- # altConfigs.txt: one org/repo/ConfigName per line; built with dotnet build after the primary build.
- # See .github/scripts/build-alt-configs.ps1 for full logic and rationale.
- # Hardcoded to the canonical DevOps_Toolkit repo so this works whether the calling repo
- # lives in the OSS org or a CSS org using the proxy pattern.
+ # altConfigs.txt: one org/repo/ConfigName per line. Build scripts are sourced from
+ # this DevOps_Toolkit checkout — the caller repo need not contain them.
- name: Checkout DevOps_Toolkit (build scripts)
if: hashFiles('altConfigs.txt') != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/ci-compliance.yml b/.github/workflows/ci-compliance.yml
index fb480c0..21db399 100644
--- a/.github/workflows/ci-compliance.yml
+++ b/.github/workflows/ci-compliance.yml
@@ -42,7 +42,7 @@ jobs:
with:
fetch-depth: 1
- # PR diff vs base SHA (two-dot); shallow fetch avoids merge-base issues on fetch-depth:1.
+ # Shallow fetch of base SHA avoids merge-base issues with fetch-depth:1.
- name: Compute changed files
id: changed
run: |
diff --git a/.github/workflows/ci-emit-health.yml b/.github/workflows/ci-emit-health.yml
index 868932b..2ee5ade 100644
--- a/.github/workflows/ci-emit-health.yml
+++ b/.github/workflows/ci-emit-health.yml
@@ -26,8 +26,6 @@ on:
required: false # BHoMBot GitHub App ID — used to mint a short-lived token for the dashboard dispatch.
BHOM_APP_PRIVATE_KEY:
required: false # BHoMBot GitHub App private key (PEM). Required when BHOM_APP_ID is set.
- TEAMS_WEBHOOK_URL:
- required: false # Teams incoming webhook URL for post-merge failure notifications.
jobs:
emit-health:
@@ -54,6 +52,7 @@ jobs:
- name: Generate dashboard token
id: dash-token
if: steps.app-check.outputs.available == 'true'
+ continue-on-error: true # bad key/App ID must not kill Build health payload or Create deployment status
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.BHOM_APP_ID }}
@@ -147,6 +146,7 @@ jobs:
# develop/main) are excluded to avoid polluting the Environments list.
- name: Create deployment status (post-merge only)
if: steps.payload.outputs.trigger != 'pull_request' && steps.payload.outputs.trigger != ''
+ continue-on-error: true # deployment API is optional telemetry; failure must not surface as CI error
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -179,9 +179,10 @@ jobs:
-f environment_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Dispatch the run record to CI_Health_Dashboard for aggregation and trend tracking.
- # Skipped gracefully when App credentials are absent (prototype repos, forks).
+ # Skipped gracefully when App credentials are absent or token generation failed (prototype repos, forks).
- name: Dispatch to dashboard
- if: steps.app-check.outputs.available == 'true'
+ if: steps.dash-token.outcome == 'success' && steps.payload.outputs.json != ''
+ continue-on-error: true # external repo availability is outside our control
shell: bash
env:
GH_TOKEN: ${{ steps.dash-token.outputs.token }}
@@ -195,83 +196,4 @@ jobs:
"repos/${{ github.repository_owner }}/CI_Health_Dashboard/dispatches" \
--input -
- # Notify the shared DevOps Teams channel on post-merge failures.
- # Skipped on PR runs (those are expected to fail; only branch health matters)
- # and when no webhook URL is configured.
- - name: Notify Teams on post-merge failure
- if: |
- steps.payload.outputs.trigger != 'pull_request' &&
- steps.payload.outputs.trigger != '' &&
- steps.payload.outputs.overall == 'failure'
- shell: bash
- env:
- TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }}
- OVERALL: ${{ steps.payload.outputs.overall }}
- RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- FORMAT_RESULT: ${{ inputs.format_result }}
- COMPLIANCE_RESULT: ${{ inputs.compliance_result }}
- DATASET_RESULT: ${{ inputs.dataset_result }}
- BUILD_RESULT: ${{ inputs.build_result }}
- UNIT_TESTS_RESULT: ${{ inputs.unit_tests_result }}
- run: |
- set -euo pipefail
- # Skip gracefully when no webhook URL is configured (secret absent or empty).
- if [ -z "${TEAMS_WEBHOOK_URL:-}" ]; then
- echo "::notice::TEAMS_WEBHOOK_URL is not set — Teams notification skipped."
- exit 0
- fi
-
- # Build a concise list of failed jobs for the message body.
- FAILED_JOBS=""
- declare -A JOB_RESULTS=(
- [format]="$FORMAT_RESULT"
- [compliance]="$COMPLIANCE_RESULT"
- [dataset]="$DATASET_RESULT"
- [build]="$BUILD_RESULT"
- [unit-tests]="$UNIT_TESTS_RESULT"
- )
- for JOB in format compliance dataset build unit-tests; do
- R="${JOB_RESULTS[$JOB]}"
- case "$R" in failure|cancelled|error)
- FAILED_JOBS="${FAILED_JOBS}\n- ${JOB} (${R})"
- ;;
- esac
- done
-
- REPO="${{ github.repository }}"
- REF="${{ github.ref_name }}"
- SHA="${{ github.sha }}"
- SHORT_SHA="${SHA:0:7}"
-
- jq -nc \
- --arg repo "$REPO" \
- --arg ref "$REF" \
- --arg sha "$SHORT_SHA" \
- --arg run_url "$RUN_URL" \
- --arg failed_jobs "$FAILED_JOBS" \
- '{
- "@type": "MessageCard",
- "@context": "https://schema.org/extensions",
- "themeColor": "CF222E",
- "summary": ("Post-merge CI failure: " + $repo + " (" + $ref + ")"),
- "sections": [{
- "activityTitle": ("\ud83d\udea8 Post-merge CI failure"),
- "activitySubtitle": ($repo + " · " + $ref + " · " + $sha),
- "facts": [
- {"name": "Repository", "value": $repo},
- {"name": "Branch", "value": $ref},
- {"name": "Commit", "value": $sha},
- {"name": "Failed jobs","value": $failed_jobs}
- ],
- "markdown": true
- }],
- "potentialAction": [{
- "@type": "OpenUri",
- "name": "View run",
- "targets": [{"os": "default", "uri": $run_url}]
- }]
- }' | \
- curl -s -X POST -H "Content-Type: application/json" \
- --data @- \
- "$TEAMS_WEBHOOK_URL"
diff --git a/.github/workflows/ci-orchestrator.yml b/.github/workflows/ci-orchestrator.yml
index afd5411..01170a5 100644
--- a/.github/workflows/ci-orchestrator.yml
+++ b/.github/workflows/ci-orchestrator.yml
@@ -1,8 +1,18 @@
name: CI Orchestrator
on:
- pull_request:
workflow_call:
+ inputs:
+ dotnet_version:
+ type: string
+ required: false
+ default: "8.0"
+ description: ".NET SDK version passed to all child workflows (build, compliance, dataset, unit-tests, format)."
+ configuration:
+ type: string
+ required: false
+ default: "Release"
+ description: "Build configuration (Release/Debug) passed to all child workflows."
secrets:
GH_TOKEN:
required: false # optional PAT for private deps; else github.token
@@ -10,12 +20,9 @@ on:
required: false # BHoMBot GitHub App ID — used to mint a short-lived token for cross-org dep cloning.
BHOM_APP_PRIVATE_KEY:
required: false # BHoMBot GitHub App private key (PEM). Required when BHOM_APP_ID is set.
- TEAMS_WEBHOOK_URL:
- required: false # Teams incoming webhook URL for post-merge failure notifications.
-# Under workflow_call, github.workflow is the caller's name; suffix avoids deadlock with caller.
-# event_name is included so PR runs and post-merge (push) runs on the same branch use
-# separate concurrency queues and do not cancel each other.
+# github.ref and event_name prevent collisions when the orchestrator is invoked
+# concurrently for different repos/branches via the ruleset engine.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-orchestrator
cancel-in-progress: true
@@ -37,16 +44,46 @@ jobs:
format_files_changed: ${{ steps.changed.outputs.format_files_changed }}
compliance_files_changed: ${{ steps.changed.outputs.compliance_files_changed }}
dataset_files_changed: ${{ steps.changed.outputs.dataset_files_changed }}
+ build_relevant_changed: ${{ steps.changed.outputs.build_relevant_changed }}
+ dotnet_version: ${{ steps.resolve-inputs.outputs.dotnet_version }}
+ configuration: ${{ steps.resolve-inputs.outputs.configuration }}
steps:
+ # inputs context is empty under pull_request; fall back to defaults.
+ - name: Resolve inputs
+ id: resolve-inputs
+ shell: bash
+ run: |
+ echo "dotnet_version=${{ inputs.dotnet_version || '8.0' }}" >> "$GITHUB_OUTPUT"
+ echo "configuration=${{ inputs.configuration || 'Release' }}" >> "$GITHUB_OUTPUT"
+
+ # Governance branches (opened by the editorconfig distribution workflow) only change
+ # .editorconfig — no job can meaningfully fail. Short-circuit here so every subsequent
+ # step is skipped, leaving run_build, run_unit_tests, and state empty. Empty outputs
+ # evaluate as != 'true', which naturally suppresses all child jobs without extra
+ # conditions. Works for both direct pull_request triggers and ruleset invocations,
+ # where paths-ignore on the trigger is bypassed by the ruleset engine.
+ - name: Detect governance branch
+ id: governance
+ shell: bash
+ run: |
+ if [[ "${{ github.head_ref }}" == governance/* ]]; then
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::Governance branch — all CI jobs will be skipped."
+ else
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Checkout
+ if: steps.governance.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- # Skip PR-only child runners (format, compliance, dataset) when no relevant files changed.
- # build and unit-tests run unconditionally on every event.
+ # format, compliance, and dataset are PR-only and skipped when no relevant files changed.
+ # build and unit-tests run on every PR unless only documentation/config files changed.
- name: Detect changed file types
+ if: steps.governance.outputs.skip != 'true'
id: changed
shell: bash
run: |
@@ -56,6 +93,8 @@ jobs:
echo "format_files_changed=false" >> "$GITHUB_OUTPUT"
echo "compliance_files_changed=false" >> "$GITHUB_OUTPUT"
echo "dataset_files_changed=false" >> "$GITHUB_OUTPUT"
+ # build_relevant_changed is not set for non-PR events; job conditions treat
+ # non-PR runs as always build-relevant via the github.event_name guard.
exit 0
fi
@@ -79,8 +118,7 @@ jobs:
echo "::notice::No compliance-relevant files changed — compliance job will be skipped."
fi
- # dataset: *.json files whose path contains a 'datasets' path segment — mirrors BHoMBot's Contains("datasets") filter.
- # The (^|/) anchor ensures 'datasets' is a path component, not just a substring of a filename.
+ # dataset: *.json under a 'datasets/' path segment (anchored to prevent substring matches).
if echo "$CHANGED" | grep -qiE '(^|/)datasets[^/]*\.json$'; then
echo "dataset_files_changed=true" >> "$GITHUB_OUTPUT"
else
@@ -88,9 +126,19 @@ jobs:
echo "::notice::No dataset files changed — dataset job will be skipped."
fi
- # Token is local to this job; child workflows each regenerate their own via the same pattern.
- # secrets context is unavailable in step `if:` expressions, so we gate on a prior step's output.
+ # build/unit-tests: skip on PRs when every changed file is in the known-safe set
+ # (documentation and plain config). Any file outside this set is potentially
+ # build-relevant, so the conservative default is to run.
+ if echo "$CHANGED" | grep -qvE '\.(md|txt|editorconfig|gitattributes|gitignore)$'; then
+ echo "build_relevant_changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "build_relevant_changed=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Only documentation/config files changed — build and unit-test jobs will be skipped."
+ fi
+
+ # secrets context is unavailable in step if: expressions; gate on a prior step's output.
- name: Check App credentials
+ if: steps.governance.outputs.skip != 'true'
id: app-check
shell: bash
env:
@@ -104,7 +152,7 @@ jobs:
- name: Generate dependency token
id: dep-token
- if: steps.app-check.outputs.available == 'true'
+ if: steps.governance.outputs.skip != 'true' && steps.app-check.outputs.available == 'true'
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.BHOM_APP_ID }}
@@ -112,26 +160,28 @@ jobs:
owner: ${{ github.repository_owner }}
- name: Read repo maturity
+ if: steps.governance.outputs.skip != 'true'
id: state
env:
GH_TOKEN: ${{ steps.dep-token.outputs.token || secrets.GH_TOKEN || github.token }}
run: |
set -euo pipefail
+
+ # Fork PRs: GITHUB_TOKEN is scoped to the fork and cannot read the parent repo's
+ # custom properties. Configure BHOM_APP_ID, or set maturity on the fork itself.
+ # See README.md — "Fork PR behaviour".
STATE=$(gh api "repos/${{ github.repository }}/properties/values" \
--jq '.[] | select(.property_name == "maturity") | .value // empty')
if [ -z "$STATE" ]; then
- echo "::error::Repository custom property 'maturity' is not set. Set it to one of: prototype | alpha | beta"
+ echo "::error::Repository custom property 'maturity' is not set. Set it to one of: $(IFS=" | "; echo "${POLICY_STATES[*]:-prototype | alpha | beta}")."
exit 1
fi
- case "$STATE" in
- prototype|alpha|beta) ;;
- *) echo "::error::Invalid maturity value '$STATE'. Must be exactly: prototype | alpha | beta"; exit 1 ;;
- esac
+ # Value validation is deferred to 'Validate repo maturity' below (post-checkout).
echo "state=$STATE" >> "$GITHUB_OUTPUT"
- echo "::notice::Repo maturity: $STATE"
# Same org default token can read this repo; pass GH_TOKEN if the toolkit is private elsewhere.
- name: Checkout policy (DevOps_Toolkit)
+ if: steps.governance.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ github.repository_owner }}/DevOps_Toolkit
@@ -140,9 +190,31 @@ jobs:
token: ${{ steps.dep-token.outputs.token || secrets.GH_TOKEN || github.token }}
- name: Validate policy.json
+ if: steps.governance.outputs.skip != 'true'
run: bash _central/.github/scripts/orchestrator/validate-policy.sh
+ # Validates against POLICY_STATES from policy-contract.sh — the single source of truth.
+ # A new tier added to the contract is enforced here automatically.
+ - name: Validate repo maturity
+ if: steps.governance.outputs.skip != 'true'
+ env:
+ STATE: ${{ steps.state.outputs.state }}
+ run: |
+ set -euo pipefail
+ # shellcheck source=_central/.github/scripts/orchestrator/policy-contract.sh
+ source _central/.github/scripts/orchestrator/policy-contract.sh
+ valid=0
+ for s in "${POLICY_STATES[@]}"; do
+ [ "$s" = "$STATE" ] && valid=1
+ done
+ if [ "$valid" -eq 0 ]; then
+ echo "::error::Invalid maturity value '$STATE'. Must be one of: ${POLICY_STATES[*]}"
+ exit 1
+ fi
+ echo "::notice::Repo maturity: $STATE"
+
- name: Read policy
+ if: steps.governance.outputs.skip != 'true'
id: policy
env:
STATE: ${{ steps.state.outputs.state }}
@@ -159,8 +231,8 @@ jobs:
needs.setup.outputs.format_files_changed == 'true'
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-format.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
- dotnet_version: "8.0"
- secrets: inherit
+ dotnet_version: ${{ needs.setup.outputs.dotnet_version }}
+ # ci-format.yml declares no secrets; GITHUB_TOKEN is sufficient (public checkouts only).
compliance:
needs: setup
@@ -170,10 +242,13 @@ jobs:
needs.setup.outputs.compliance_files_changed == 'true'
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-compliance.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
- dotnet_version: "8.0"
- configuration: "Release"
+ dotnet_version: ${{ needs.setup.outputs.dotnet_version }}
+ configuration: ${{ needs.setup.outputs.configuration }}
compliance_checks: ${{ needs.setup.outputs.compliance_checks }}
- secrets: inherit
+ secrets:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
dataset:
needs: setup
@@ -183,40 +258,50 @@ jobs:
needs.setup.outputs.dataset_files_changed == 'true'
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-dataset.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
- dotnet_version: "8.0"
- configuration: "Release"
- secrets: inherit
+ dotnet_version: ${{ needs.setup.outputs.dotnet_version }}
+ configuration: ${{ needs.setup.outputs.configuration }}
+ secrets:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
build:
needs: setup
if: |
needs.setup.result == 'success' &&
- needs.setup.outputs.run_build == 'true'
+ needs.setup.outputs.run_build == 'true' &&
+ (github.event_name != 'pull_request' || needs.setup.outputs.build_relevant_changed == 'true')
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-build.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
- dotnet_version: "8.0"
- configuration: "Release"
- secrets: inherit
+ dotnet_version: ${{ needs.setup.outputs.dotnet_version }}
+ configuration: ${{ needs.setup.outputs.configuration }}
+ secrets:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
# Unit-tests resolves its own deps and builds the test solution independently of ci-build.
unit-tests:
needs: setup
if: |
needs.setup.result == 'success' &&
- needs.setup.outputs.run_unit_tests == 'true'
+ needs.setup.outputs.run_unit_tests == 'true' &&
+ (github.event_name != 'pull_request' || needs.setup.outputs.build_relevant_changed == 'true')
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-unit-tests.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
- dotnet_version: "8.0"
- configuration: "Release"
- secrets: inherit
+ dotnet_version: ${{ needs.setup.outputs.dotnet_version }}
+ configuration: ${{ needs.setup.outputs.configuration }}
+ secrets:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
# ── Health emission ─────────────────────────────────────────────────────────
- # Runs unconditionally after all child jobs (including failures and skips).
- # Delegates to ci-emit-health.yml; continue-on-error ensures a telemetry failure
- # never blocks required status checks.
+ # always() ensures this runs regardless of child job outcomes; continue-on-error
+ # in ci-emit-health.yml prevents telemetry failures from blocking required checks.
emit-health:
needs: [setup, format, compliance, dataset, build, unit-tests]
- if: always() && needs.setup.result != 'skipped'
+ if: always() && needs.setup.result != 'skipped' && needs.setup.outputs.state != ''
uses: BuroHappoldEngineeringSandbox/DevOps_Toolkit/.github/workflows/ci-emit-health.yml@main # MIGRATE-TO-PROD: update org when moving to production
with:
maturity: ${{ needs.setup.outputs.state }}
@@ -225,4 +310,6 @@ jobs:
dataset_result: ${{ needs.dataset.result }}
build_result: ${{ needs.build.result }}
unit_tests_result: ${{ needs['unit-tests'].result }}
- secrets: inherit
+ secrets:
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/governance-distribute-editorconfig.yml b/.github/workflows/governance-distribute-editorconfig.yml
deleted file mode 100644
index e536903..0000000
--- a/.github/workflows/governance-distribute-editorconfig.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: Distribute .editorconfig
-
-# Triggers whenever .editorconfig changes on main, distributing the
-# updated file to every active BHoM repo with a maturity custom property set.
-# PRs are opened as the DevOps GitHub App and auto-merge once CI passes.
-# Run workflow_dispatch with dry_run=true to preview affected repos without
-# opening any PRs.
-
-on:
- push:
- branches: [main]
- paths:
- - '.editorconfig'
- workflow_dispatch:
- inputs:
- repo:
- description: 'Single repo to target (e.g. BHoM_Adapter). Leave blank for all BHoM repos.'
- type: string
- required: false
- default: ''
- dry_run:
- description: 'Dry run — list affected repos without opening PRs'
- type: boolean
- required: false
- default: false
-
-# Never cancel a distribution mid-flight; a partial run leaves repos in
-# inconsistent states. A new push will supersede once this run finishes.
-concurrency:
- group: distribute-editorconfig
- cancel-in-progress: false
-
-jobs:
- distribute:
- runs-on: ubuntu-latest
- timeout-minutes: 60
- permissions:
- contents: read # DevOps_Toolkit checkout only; target repo writes use app token.
-
- steps:
- - name: Checkout DevOps_Toolkit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 1
-
- # Generates a short-lived (1 h) installation token scoped to the org.
- # Required secrets (org-level): BHOM_APP_ID, BHOM_APP_PRIVATE_KEY.
- # Required App permissions: Contents (rw), Pull requests (rw).
- - name: Generate app token
- id: app-token
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
- with:
- app-id: ${{ secrets.BHOM_APP_ID }}
- private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
- owner: ${{ github.repository_owner }}
-
- - name: Resolve target repos
- id: repos
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- ORG: ${{ github.repository_owner }}
- SINGLE_REPO: ${{ inputs.repo }}
- run: |
- if [ -n "${SINGLE_REPO}" ]; then
- # Single-repo mode: bypass the search API entirely.
- echo "${SINGLE_REPO}" > repo-list.txt
- echo "::notice::Single-repo mode — targeting ${SINGLE_REPO}."
- else
- # Query per maturity value using custom property search and deduplicate.
- for maturity in prototype alpha beta; do
- gh api "search/repositories?q=org:${ORG}+props.maturity:${maturity}&per_page=100" \
- --paginate \
- --jq '.items[] | select(.archived == false) | .name'
- done \
- | sort -u \
- | grep -v "^DevOps_Toolkit$" \
- > repo-list.txt
-
- COUNT=$(wc -l < repo-list.txt | tr -d ' ')
- echo "::notice::${COUNT} target repo(s) found."
- fi
-
- - name: Distribute
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- ORG: ${{ github.repository_owner }}
- DRY_RUN: ${{ inputs.dry_run || 'false' }}
- run: bash .github/scripts/distribute-editorconfig.sh repo-list.txt
diff --git a/.github/workflows/governance-editorconfig.yml b/.github/workflows/governance-editorconfig.yml
new file mode 100644
index 0000000..09edc95
--- /dev/null
+++ b/.github/workflows/governance-editorconfig.yml
@@ -0,0 +1,186 @@
+name: Distribute .editorconfig
+
+# Triggered on push to main when .editorconfig changes, or manually via workflow_dispatch.
+# Reads the org list from .github/config/governance-orgs.json and runs one parallel matrix
+# leg per org. Each leg mints its own short-lived App token scoped to that org, then opens
+# PRs in every repo with a maturity custom property set.
+# Use dry_run=true to preview affected repos without opening PRs.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - '.editorconfig'
+ workflow_dispatch:
+ inputs:
+ org:
+ description: 'Single org to target (e.g. BuroHappoldEngineering). Leave blank for all orgs.'
+ type: string
+ required: false
+ default: ''
+ repo:
+ description: 'Single repo to target (e.g. BHoM_Adapter). Leave blank for all repos in the org.'
+ type: string
+ required: false
+ default: ''
+ dry_run:
+ description: 'Dry run — list affected repos without opening PRs'
+ type: boolean
+ required: false
+ default: false
+
+# Prevent concurrent distribution runs; a partial run leaves repos in inconsistent states.
+# A newer push queues until the current run completes.
+concurrency:
+ group: distribute-editorconfig
+ cancel-in-progress: false
+
+jobs:
+ setup:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ orgs: ${{ steps.resolve-orgs.outputs.orgs }}
+
+ steps:
+ - name: Checkout DevOps_Toolkit
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 1
+
+ - name: Resolve target orgs
+ id: resolve-orgs
+ env:
+ SINGLE_ORG: ${{ inputs.org }}
+ run: |
+ if [ -n "${SINGLE_ORG}" ]; then
+ if ! echo "${SINGLE_ORG}" | grep -qE '^[A-Za-z0-9_.-]+$'; then
+ echo "::error::Invalid org name '${SINGLE_ORG}'. Only alphanumeric characters, hyphens, underscores, and dots are allowed."
+ exit 1
+ fi
+ # Validate against the manifest to prevent targeting orgs outside the governance model.
+ if ! jq -e --arg o "${SINGLE_ORG}" 'index($o) != null' .github/config/governance-orgs.json > /dev/null; then
+ echo "::error::Org '${SINGLE_ORG}' is not listed in .github/config/governance-orgs.json."
+ exit 1
+ fi
+ orgs="[\"${SINGLE_ORG}\"]"
+ else
+ orgs=$(jq -c '.' .github/config/governance-orgs.json)
+ fi
+ echo "orgs=${orgs}" >> "$GITHUB_OUTPUT"
+ echo "::notice::Target org(s): $(echo "${orgs}" | jq -r '.[]' | tr '\n' ' ')"
+
+ distribute:
+ needs: setup
+ runs-on: ubuntu-latest
+ timeout-minutes: 50 # App token TTL is 60 min; cap job below that to avoid late-run 401s.
+ permissions:
+ contents: read # DevOps_Toolkit checkout only; target repo writes use app token.
+ strategy:
+ matrix:
+ org: ${{ fromJson(needs.setup.outputs.orgs) }}
+ fail-fast: false # One org's failure does not cancel distribution to other orgs.
+
+ steps:
+ - name: Checkout DevOps_Toolkit
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 1
+
+ # Generates a short-lived (1 h) installation token scoped to the target org.
+ # Required secrets (org-level): BHOM_APP_ID, BHOM_APP_PRIVATE_KEY.
+ # Required App permissions: Contents (rw), Pull requests (rw), Organisation custom properties (read).
+ # The same App must be installed in every org listed in governance-orgs.json.
+ - name: Generate app token
+ id: app-token
+ continue-on-error: true # 404 means App is not installed; handled by the next step.
+ uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
+ with:
+ app-id: ${{ secrets.BHOM_APP_ID }}
+ private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
+ owner: ${{ matrix.org }}
+
+ # Surfaces a clear, non-disruptive warning rather than a red matrix leg when the App
+ # has not yet been installed in the target org.
+ - name: Skip — App not installed
+ if: steps.app-token.outcome == 'failure'
+ run: |
+ echo "::warning::GitHub App is not installed in '${{ matrix.org }}'. Distribution skipped."
+ echo "::warning::Install the App in that org and re-run, or remove it from .github/config/governance-orgs.json."
+
+ - name: Resolve target repos
+ if: steps.app-token.outcome == 'success'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ ORG: ${{ matrix.org }}
+ SINGLE_REPO: ${{ inputs.repo }}
+ run: |
+ if [ -n "${SINGLE_REPO}" ]; then
+ if ! echo "${SINGLE_REPO}" | grep -qE '^[A-Za-z0-9_.-]+$'; then
+ echo "::error::Invalid repo name '${SINGLE_REPO}'. Only alphanumeric characters, hyphens, underscores, and dots are allowed."
+ exit 1
+ fi
+ # Write directly to repo-list.txt, bypassing the properties API.
+ echo "${SINGLE_REPO}" > repo-list.txt
+ echo "::notice::Single-repo mode — targeting ${ORG}/${SINGLE_REPO}."
+ else
+ # Build a sorted, lowercased list of non-archived repos.
+ # GitHub repo names are case-insensitive; normalising to lowercase prevents
+ # comm mismatches when the properties API and repos API return different
+ # casings for the same repository name.
+ gh api "/orgs/${ORG}/repos" --paginate \
+ --jq '.[] | select(.archived == false) | .name' \
+ | tr '[:upper:]' '[:lower:]' \
+ | LC_ALL=C sort > /tmp/active-repos.txt
+
+ ACTIVE_COUNT=$(wc -l < /tmp/active-repos.txt | tr -d ' ')
+ echo "::notice::[${ORG}] ${ACTIVE_COUNT} non-archived repo(s) in org."
+
+ # Fetch all repos with their custom property values in a single call, then
+ # filter client-side to repos with 'maturity' set to a known tier.
+ #
+ # Why not a per-tier loop with repository_query=props.maturity:VALUE?
+ # 'repository_query' is a repo-name text search, not a property value filter.
+ # Every tier returned 404; gh api writes the error JSON body to stdout (not
+ # stderr), so 2>/dev/null did not suppress it. The JSON leaked through the
+ # pipeline into props-repos.txt and poisoned the comm intersection.
+ source .github/scripts/orchestrator/policy-contract.sh
+ maturity_pattern=$(IFS='|'; echo "${POLICY_STATES[*]}")
+ gh api "/orgs/${ORG}/properties/values" \
+ --paginate \
+ --jq ".[] | select(any(.properties[]; .property_name == \"maturity\" and (.value != null) and (.value | test(\"^(${maturity_pattern})$\")))) | .repository_name" \
+ | tr '[:upper:]' '[:lower:]' \
+ | LC_ALL=C sort -u \
+ | grep -v "^devops_toolkit$" \
+ > /tmp/props-repos.txt
+
+ PROPS_COUNT=$(wc -l < /tmp/props-repos.txt | tr -d ' ')
+ echo "::notice::[${ORG}] ${PROPS_COUNT} repo(s) have maturity property set."
+ if [ "${PROPS_COUNT}" -gt 0 ]; then
+ echo "::notice::[${ORG}] Repos with maturity set: $(tr '\n' ' ' < /tmp/props-repos.txt)"
+ fi
+
+ if [ "${PROPS_COUNT}" -eq 0 ] && [ "${ACTIVE_COUNT}" -gt 0 ]; then
+ echo "::warning::[${ORG}] No repos have maturity set, but ${ACTIVE_COUNT} non-archived repo(s) exist. Check that the 'maturity' custom property is defined and assigned in this org."
+ fi
+
+ LC_ALL=C comm -12 /tmp/props-repos.txt /tmp/active-repos.txt > repo-list.txt
+
+ COUNT=$(wc -l < repo-list.txt | tr -d ' ')
+ echo "::notice::[${ORG}] ${COUNT} repo(s) targeted for distribution."
+ if [ "${COUNT}" -eq 0 ] && [ "${PROPS_COUNT}" -gt 0 ]; then
+ echo "::warning::[${ORG}] Intersection produced 0 repos despite ${PROPS_COUNT} having maturity set."
+ echo "::warning::[${ORG}] props-repos: $(tr '\n' '|' < /tmp/props-repos.txt)"
+ echo "::warning::[${ORG}] active-repos (first 10): $(head -10 /tmp/active-repos.txt | tr '\n' '|')"
+ fi
+ fi
+
+ - name: Distribute
+ if: steps.app-token.outcome == 'success'
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ ORG: ${{ matrix.org }}
+ DRY_RUN: ${{ inputs.dry_run || 'false' }}
+ run: bash .github/scripts/distribute-editorconfig.sh repo-list.txt
diff --git a/.github/workflows/governance-sync-codeowners.yml b/.github/workflows/governance-sync-codeowners.yml
deleted file mode 100644
index aba8889..0000000
--- a/.github/workflows/governance-sync-codeowners.yml
+++ /dev/null
@@ -1,113 +0,0 @@
-name: Sync CODEOWNERS
-
-# Manually triggered — run this once team assignments have settled to open
-# PRs that update .github/CODEOWNERS in each target repo to reflect the
-# current GitHub team assignments.
-#
-# PRs are opened as the DevOps GitHub App. Each PR targets the repo's
-# develop branch and must be reviewed and merged by the platform team
-# (who are the CODEOWNERS of .github/ in every repo).
-
-on:
- workflow_dispatch:
- inputs:
- repo:
- description: 'Single repo to target (e.g. BHoM_Adapter). Leave blank to use team scope or all repos.'
- type: string
- required: false
- default: ''
- team:
- description: 'Team slug to target (e.g. structures). Syncs all repos owned by that team. Leave blank for all repos.'
- type: string
- required: false
- default: ''
- dry_run:
- description: 'Dry run — list affected repos without opening PRs'
- type: boolean
- required: false
- default: false
-
-# Never cancel a sync mid-flight; a partial run leaves repos in
-# inconsistent states. Let the current run finish before starting another.
-concurrency:
- group: sync-codeowners
- cancel-in-progress: false
-
-jobs:
- sync:
- runs-on: ubuntu-latest
- timeout-minutes: 60
- permissions:
- contents: read # DevOps_Toolkit checkout only; target repo writes use app token.
-
- steps:
- - name: Checkout DevOps_Toolkit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 1
-
- - name: Install yq
- run: |
- YQ_VERSION="v4.44.3"
- sudo wget -qO /usr/local/bin/yq \
- "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
- sudo chmod +x /usr/local/bin/yq
- yq --version
-
- # Generates a short-lived (1 h) installation token scoped to the org.
- # Required secrets (org-level): BHOM_APP_ID, BHOM_APP_PRIVATE_KEY.
- # Required App permissions: Contents (rw), Pull requests (rw), Organisation Members (read).
- - name: Generate app token
- id: app-token
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
- with:
- app-id: ${{ secrets.BHOM_APP_ID }}
- private-key: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
- owner: ${{ github.repository_owner }}
-
- - name: Resolve target repos
- id: repos
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- ORG: ${{ github.repository_owner }}
- SINGLE_REPO: ${{ inputs.repo }}
- SINGLE_TEAM: ${{ inputs.team }}
- run: |
- if [ -n "${SINGLE_REPO}" ]; then
- echo "${SINGLE_REPO}" > repo-list.txt
- echo "::notice::Single-repo mode — targeting ${SINGLE_REPO}."
- elif [ -n "${SINGLE_TEAM}" ]; then
- yq eval ".teams[\"${SINGLE_TEAM}\"] // [] | .[]" config/repo-ownership.yml > repo-list.txt 2>/dev/null || true
- COUNT=$(wc -l < repo-list.txt | tr -d ' ')
- if [ "$COUNT" -eq 0 ]; then
- echo "::error::Team '${SINGLE_TEAM}' has no repos in config/repo-ownership.yml. Check the team slug and re-run."
- exit 1
- fi
- REPOS=$(tr '\n' ', ' < repo-list.txt | sed 's/,$//')
- echo "::notice::Team-scope mode — targeting ${COUNT} repo(s) for team '${SINGLE_TEAM}': ${REPOS}."
- else
- gh api "orgs/${ORG}/repos?type=public&per_page=100" \
- --paginate \
- --jq '.[] | select(.archived == false) | .name' \
- | grep -v "^DevOps_Toolkit$" \
- | sort -u \
- > repo-list.txt
-
- COUNT=$(wc -l < repo-list.txt | tr -d ' ')
- echo "::notice::${COUNT} target repo(s) found."
-
- # Warn about repos in the ownership map that no longer exist in the org.
- while IFS=$'\t' read -r _team map_repo; do
- [ -z "$map_repo" ] && continue
- if ! grep -qx "$map_repo" repo-list.txt; then
- echo "::warning::'${map_repo}' is listed in config/repo-ownership.yml but was not found in the org. Remove or update the entry."
- fi
- done < <(yq eval '.teams | to_entries | .[] | .key as $team | .value[] | [$team, .] | @tsv' config/repo-ownership.yml 2>/dev/null || true)
- fi
-
- - name: Sync CODEOWNERS
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- ORG: ${{ github.repository_owner }}
- DRY_RUN: ${{ inputs.dry_run || 'false' }}
- run: bash .github/scripts/sync-codeowners.sh repo-list.txt
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2b0b587
--- /dev/null
+++ b/README.md
@@ -0,0 +1,290 @@
+# DevOps_Toolkit
+
+A centralised GitHub Actions CI toolkit for .NET repositories. Provides a single
+reusable orchestrator workflow that runs format checking, compliance analysis, dataset
+validation, build, and unit tests — gated by a per-repository maturity level.
+
+Designed to be consumed by multiple repositories across one or more GitHub organisations.
+
+---
+
+## How it works
+
+Each consuming repository calls the CI orchestrator from its own workflow file. The
+orchestrator reads the repository's `maturity` custom property and a central `policy.json`
+to decide which checks to run, then dispatches those checks as parallel jobs.
+
+```
+Your repo → .github/workflows/ci.yml
+ └── calls ci-orchestrator.yml@main
+ ├── setup (reads maturity + policy, detects changed files)
+ ├── format (dotnet format — PR diffs only)
+ ├── compliance (BHoM compliance rules — PR diffs only)
+ ├── dataset (dataset JSON validation — PR diffs only)
+ ├── build (dotnet build)
+ ├── unit-tests (dotnet test — if .ci/unit-tests/ exists)
+ └── emit-health (CI health dashboard dispatch)
+```
+
+Checks not enabled for a repository's maturity level are skipped entirely — they never
+appear as failed or pending required status checks.
+
+---
+
+## Prerequisites
+
+### 1. Repository custom property: `maturity`
+
+The orchestrator fails immediately if this property is absent. It controls which checks
+run against your repository.
+
+Set it under **GitHub → Organisation settings → Repository custom properties**, or
+per-repository under **Settings → Custom properties**.
+
+| Value | Checks enabled |
+|-------|---------------|
+| `prototype` | format |
+| `alpha` | format · compliance (copyright + project rules) · build · unit-tests |
+| `beta` | all of the above + documentation + code rules + dataset validation |
+
+Start with `prototype` for new repositories and promote when ready.
+
+### 2. GitHub App (optional, required for private dependencies)
+
+Without a GitHub App the orchestrator falls back to `GITHUB_TOKEN`, which is scoped
+to the current repository. This is sufficient when all dependency repositories are public.
+
+If any repository in your dependency graph is private, configure a GitHub App and store
+its credentials as **organisation-level** secrets (or per-repository if preferred):
+
+| Secret name | Description |
+|-------------|-------------|
+| `BHOM_APP_ID` | Numeric App ID — visible on the App's settings page |
+| `BHOM_APP_PRIVATE_KEY` | PEM-format private key — generated under the App settings |
+
+**Required App permissions:**
+
+| Scope | Permission | Required for |
+|-------|-----------|--------------|
+| Repository: Contents | Read | Cloning private dependency repos |
+| Repository: Pull requests | Read | Compliance and format steps read PR metadata |
+
+**App installation:** install the App on every GitHub organisation whose repositories
+appear in any `dependencies.txt` file across your fleet.
+
+> If you are adopting this toolkit outside the BHoM organisation, create your own GitHub
+> App. The App name is not significant — only the numeric ID and PEM key are consumed.
+
+### 3. DevOps_Toolkit must be readable
+
+The orchestrator checks out a copy of this repository at runtime to read `policy.json`.
+If this repository is private, the App (or a PAT stored as `GH_TOKEN`) must have
+`Contents: read` access to it. If it is public, no extra configuration is required.
+
+---
+
+## Integrating the orchestrator
+
+### Step 1 — Copy the caller workflow
+
+Copy [`templates/caller-ci.yml`](templates/caller-ci.yml) to your repository at:
+
+```
+.github/workflows/ci.yml
+```
+
+Edit the single placeholder on the `uses:` line:
+
+```yaml
+uses: YOUR_ORG/DevOps_Toolkit/.github/workflows/ci-orchestrator.yml@main
+```
+
+Replace `YOUR_ORG` with the GitHub organisation (or user account) that hosts your
+DevOps_Toolkit instance.
+
+### Step 2 — Set the maturity property
+
+Set `maturity` to `prototype`, `alpha`, or `beta` on the repository before the first
+workflow run. The orchestrator hard-fails if the property is absent.
+
+### Step 3 — Add secrets
+
+Add `BHOM_APP_ID` and `BHOM_APP_PRIVATE_KEY` at organisation level if you have private
+dependencies. The workflow treats both as optional — missing credentials cause a graceful
+fallback to `GITHUB_TOKEN`, not a failure. Public-dependency repositories need no secrets.
+
+### Step 4 — Open a pull request
+
+The first run executes the `setup` job, reads policy, and runs only the checks appropriate
+for your maturity level against the files changed in the PR.
+
+---
+
+## Configuration reference
+
+### Caller workflow inputs
+
+Passed via the `with:` block when calling the orchestrator. Both are optional.
+
+| Input | Type | Default | Description |
+|-------|------|---------|-------------|
+| `dotnet_version` | string | `"8.0"` | .NET SDK version used by build, compliance, dataset, unit-test, and format jobs |
+| `configuration` | string | `"Release"` | MSBuild configuration passed to all build steps |
+
+Example:
+
+```yaml
+jobs:
+ ci:
+ uses: YOUR_ORG/DevOps_Toolkit/.github/workflows/ci-orchestrator.yml@main
+ with:
+ dotnet_version: "9.0"
+ configuration: "Release"
+ secrets:
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
+```
+
+### `dependencies.txt`
+
+List repositories that must be built before your repository. One entry per line.
+Blank lines and `#` comments are ignored. Dependencies are resolved transitively.
+
+```
+# Syntax: org/repo or org/repo@ref
+BHoM/BHoM
+BHoM/BHoM_Engine
+BHoM/BHoM_Adapter@feature/my-branch # pin to a specific ref
+```
+
+### Unit tests
+
+Place a `.sln` file under `.ci/unit-tests/` to enable the unit-test job. The job
+discovers the solution automatically; no configuration is required.
+
+### Alt build configurations
+
+List additional MSBuild configurations in `altConfigs.txt` at the repo root, one per
+line in `org/repo/ConfigName` format. The `build` job runs these after the primary build.
+
+---
+
+## What is opinionated (not configurable per repository)
+
+- **Which checks run at each maturity level.** Controlled by `policy.json` in
+ DevOps_Toolkit. This is a platform governance decision, not a per-team choice.
+ To request a change, open a PR against this repository.
+- **Runner OS.** Build, compliance, dataset, and unit-test jobs run on `windows-latest`.
+ This is required by the BHoM assembly staging convention
+ (`C:\ProgramData\BHoM\Assemblies`).
+- **Dependency clone root.** Dependencies are cloned to `C:\bhom-deps\` on the runner.
+- **Branch resolution order for dependencies.** PR head branch → PR base branch →
+ `develop` → remote default. Explicit `@ref` pins in `dependencies.txt` always win.
+- **Compliance rules.** The set of available compliance checks and their behaviour are
+ defined in the ComplianceRunner tool in this repository.
+
+---
+
+## Fork PR behaviour
+
+The `setup` job reads the `maturity` custom property via the GitHub API. On fork pull
+requests, `GITHUB_TOKEN` is scoped to the fork and may not have access to the parent
+repository's custom properties. This causes `setup` to fail with:
+
+```
+::error::Repository custom property 'maturity' is not set.
+```
+
+**Options for repositories that accept fork contributions:**
+
+1. **Configure the GitHub App** (`BHOM_APP_ID` / `BHOM_APP_PRIVATE_KEY`) at organisation
+ level. The App token is minted before the properties lookup and has org-wide read
+ access, so the lookup succeeds for both fork and non-fork PRs.
+
+2. **Set the `maturity` property on the fork itself.** The API call will resolve the
+ fork's own property value. Only practical for known, trusted forks.
+
+3. **Do not use this workflow as a required status check for fork PRs.** Accept that
+ external forks will see a failing `setup` job, and gate merges on a maintainer review
+ rather than CI.
+
+---
+
+## Required status checks
+
+After CI passes on an initial PR, configure required status checks on your `develop` /
+`main` branches under **Settings → Branches → Branch protection rules**:
+
+```
+ci / setup
+ci / build (maturity: alpha or beta)
+ci / unit-tests (maturity: alpha or beta)
+```
+
+`format` and `compliance` are PR-only checks (they are skipped on push events). Do not
+list them as required checks on branch rules — they will show as `skipped`, not
+`passing`, on push-triggered runs and would permanently block merges.
+
+---
+
+## Upgrading maturity
+
+1. Update the `maturity` custom property on the repository.
+2. Open a PR — the orchestrator will now run the additional checks enabled by the new
+ maturity level.
+3. Fix any new failures before merging.
+4. Update your branch protection rules to require the newly enabled jobs.
+
+There is no automated promotion step. Maturity upgrades are intentionally manual.
+
+---
+
+## Troubleshooting
+
+### `Repository custom property 'maturity' is not set`
+
+The `maturity` property was not set before enabling CI, or the token used to read it
+lacks access (see [Fork PR behaviour](#fork-pr-behaviour)).
+**Fix:** Set `maturity` on the repository. For fork PRs, configure the GitHub App.
+
+### `Invalid maturity value '...'`
+
+The property is set to a value not listed in `policy.json`.
+**Fix:** Set it to exactly `prototype`, `alpha`, or `beta`.
+
+### `No .sln file found in repository`
+
+The `build` job expects a `.sln` file at the repo root named `.sln`, or at
+minimum one `.sln` anywhere in the repo.
+**Fix:** Ensure a solution file exists. The build job will find the first `.sln`
+alphabetically if `.sln` is absent (a notice is emitted).
+
+### `packages.config detected — legacy NuGet/MSBuild is not supported`
+
+The toolkit only supports SDK-style projects.
+**Fix:** Migrate all projects from `packages.config` to `` before onboarding.
+
+### Dependency clone fails / `assembly not found` at build time
+
+A private repository in `dependencies.txt` could not be cloned because no credentials
+with access to it are configured.
+**Fix:** Configure the GitHub App and ensure it is installed on the organisation that
+owns the dependency repository. Check the `Resolve dependencies` step log for the
+specific error.
+
+### `Clone not found at C:\bhom-deps\` (hard error)
+
+Dependency resolution recorded a repository in the build order but the clone was never
+written to disk — typically an auth failure or network interruption.
+**Fix:** Check the `Resolve dependencies` step log for earlier errors.
+
+### `policy.json is not valid` / `missing required key`
+
+`policy.json` in this repository does not match the expected schema.
+**Fix:** This is a platform-level issue — open a PR against DevOps_Toolkit to correct
+`policy.json`. Do not edit policy in consuming repositories.
+
+### Format check fails on files I didn't change
+
+A prior commit introduced a formatting issue that `dotnet format` now surfaces.
+**Fix:** Run `dotnet format` locally, commit the result, and push.
diff --git a/config/repo-ownership.yml b/config/repo-ownership.yml
deleted file mode 100644
index 288e0f2..0000000
--- a/config/repo-ownership.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-# Discipline team ownership map — managed in DevOps_Toolkit, do not edit in target repos.
-#
-# Structure: each team key lists the repos it owns.
-#
-# Team rename: change the key here AND the slug in GitHub org settings, then re-run the sync workflow.
-# Repo transfer: move the repo name from one team block to another, then re-run.
-# New repo: add the repo name under the appropriate team block, then run the sync workflow with repo= input.
-#
-# Rules:
-# - Every active repo must appear exactly once across all team blocks.
-# - Team slugs must be lowercase and hyphenated (e.g. design-technology).
-# - Repo names must match GitHub repository names exactly (case-sensitive).
-# - Repos listed under the platform team generate a single-line CODEOWNERS
-# (platform owns everything); no separate path overrides are added.
-
-teams:
-
- # Example entries — replace with actual team slugs and repos.
- #
- # structures:
- # - BHoM_Adapter
- # - BHoM_Engine
- # - Structure_Toolkit
- #
- # design-technology:
- # - Revit_Toolkit
- # - Grasshopper_Toolkit
- #
- # platform:
- # - BHoMBot
- # - ci-templates
-
diff --git a/templates/caller-ci.yml b/templates/caller-ci.yml
new file mode 100644
index 0000000..6ed29a7
--- /dev/null
+++ b/templates/caller-ci.yml
@@ -0,0 +1,36 @@
+# Template: copy this file to .github/workflows/ci.yml in your repository.
+#
+# Calls the DevOps_Toolkit CI orchestrator, which runs format checking,
+# compliance analysis, dataset validation, build, and unit tests according
+# to your repository's maturity level (prototype | alpha | beta).
+#
+# Before enabling this workflow, complete the prerequisites in README.md.
+
+name: CI
+
+on:
+ pull_request:
+ paths-ignore:
+ - '.editorconfig' # Config-only changes cannot affect build or test outcomes.
+ push:
+ branches:
+ - main
+ - develop
+
+# Prevent duplicate runs: cancel any in-flight run for the same ref/event pair
+# when a new one is triggered.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
+ cancel-in-progress: true
+
+jobs:
+ ci:
+ # Replace the org below with the org that hosts your DevOps_Toolkit instance.
+ uses: YOUR_ORG/DevOps_Toolkit/.github/workflows/ci-orchestrator.yml@main
+ secrets:
+ BHOM_APP_ID: ${{ secrets.BHOM_APP_ID }}
+ BHOM_APP_PRIVATE_KEY: ${{ secrets.BHOM_APP_PRIVATE_KEY }}
+ # Optional overrides — omit to use the defaults (dotnet_version: "8.0", configuration: "Release").
+ # with:
+ # dotnet_version: "9.0"
+ # configuration: "Release"