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"