diff --git a/.github/workflows/sync-files.yml b/.github/workflows/sync-files.yml new file mode 100644 index 0000000..a2a9955 --- /dev/null +++ b/.github/workflows/sync-files.yml @@ -0,0 +1,24 @@ +name: Sync Managed Files + +on: + schedule: + - cron: '0 6 * * *' # Daily at 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync-files: + name: Sync files to subscribing repositories + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Sync managed files + uses: PSModule/GitHub-Script@e3b0111c93df3686061cb2c65054f9216ed265e5 # main + with: + Script: ./scripts/Sync-Files.ps1 + ClientID: ${{ secrets.CUSTO_BOT_CLIENT_ID }} + PrivateKey: ${{ secrets.CUSTO_BOT_PRIVATE_KEY }} diff --git a/README.md b/README.md index f1bda8f..1a4346e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,118 @@ # Custo -Central managed-file distribution and compliance orchestration for MSX initiatives + +Central managed-file distribution and compliance orchestration for MSX initiatives. +Custo is the **MSXOrg** counterpart of [PSModule/Distributor](https://github.com/PSModule/Distributor) — it pushes shared files (AGENTS.md, coding standards, settings) to subscribing repositories across one or more GitHub organizations via pull requests. + +## Ownership split + +| Layer | Owner | What lives there | +|-------|-------|-----------------| +| **Policy & process** | [PSModule/docs](https://github.com/PSModule/docs) · [MSXOrg/docs](https://github.com/MSXOrg/docs) | Coding standards, ways of working, AGENTS instructions that agents read | +| **Runtime** | MSXOrg/Custo (this repo) | Sync workflow, script, file-set content that gets pushed to target repos | + +Changes to _what agents do_ → edit PSModule/docs or MSXOrg/docs. +Changes to _how files are distributed_ → edit this repo. + +## How it works + +1. **File sets** live under `Repos///` — any file in a set is synced verbatim to the target repo root. +2. **Target organizations** are listed in `config/targets.json`. +3. The workflow queries each org for repositories with GitHub Custom Properties: + - `Type` — maps to a `Repos//` folder (e.g. `Module`). + - `SubscribeTo` — comma-separated list of set names within that type (e.g. `AGENTS.md`). +4. For each match the script clones the target repo, copies the files, and creates/updates a PR titled `⚙️ [Maintenance]: Sync managed files` on branch `managed-files/update`. +5. **No implicit deletes** — only files explicitly present in the selected sets are touched; files not in any set are left alone. +6. **No duplicate PRs** — if an open PR already exists on the same branch, it is updated rather than re-created. + +## Repository layout + +``` +.github/ + workflows/ + sync-files.yml # Schedule: daily 06:00 UTC + manual dispatch +config/ + targets.json # List of target GitHub orgs +Repos/ + Module/ + AGENTS.md/ + AGENTS.md # Thin-pointer instructions for PSModule module repos +scripts/ + Sync-Files.ps1 # Core sync logic (config-driven) +``` + +## Required secrets + +Set these as **repository secrets** in MSXOrg/Custo (Settings → Secrets and variables → Actions): + +| Secret | Description | +|--------|-------------| +| `CUSTO_BOT_CLIENT_ID` | GitHub App Client ID for the Custo bot app | +| `CUSTO_BOT_PRIVATE_KEY` | GitHub App private key (PEM format) | + +The GitHub App must be **installed** on every target organization and granted the following permissions: + +| Permission | Level | Reason | +|------------|-------|--------| +| Contents | Read & Write | Clone repos, commit files, push branch | +| Pull requests | Read & Write | Create and update PRs | +| Metadata | Read | List repositories and custom properties | + +## Adding a new file set + +1. Create a folder under `Repos///` and add the files. +2. Target repos opt in by setting their `SubscribeTo` custom property to include ``. +3. On next run the script will pick up the new set automatically. + +## Adding a new target organization + +1. Edit `config/targets.json` and add the org name to the `orgs` array. +2. Install the Custo GitHub App on the new org. +3. Set `Type` and `SubscribeTo` custom properties on the repos that should receive files. + +## MVP scope + +The current MVP ships a single file set: **`Repos/Module/AGENTS.md/`** containing a thin-pointer `AGENTS.md` for PSModule PowerShell module repositories. This file instructs agents to read [PSModule/docs](https://github.com/PSModule/docs) and [MSXOrg/docs](https://github.com/MSXOrg/docs) instead of embedding the full process text. + +## Operator runbook — first AGENTS.md wave + +### Prerequisites (blockers if not done) + +1. **GitHub App exists** — create a GitHub App under the MSXOrg organization named `Custo Bot` (or similar) with the permissions listed above. Download the private key. +2. **App installed on PSModule org** — install the app from the MSXOrg org settings onto the `PSModule` organization, granting access to all repositories (or at minimum the module repos that should receive the sync). +3. **Secrets configured** — add `CUSTO_BOT_CLIENT_ID` and `CUSTO_BOT_PRIVATE_KEY` to MSXOrg/Custo repository secrets. +4. **Custom properties on target repos** — for each PSModule module repository that should receive the `AGENTS.md`: + - Set custom property `Type` = `Module` + - Set custom property `SubscribeTo` = `AGENTS.md` + These can be set in bulk via the GitHub UI (Org Settings → Custom Properties) or the `gh` CLI: + ```bash + gh api -X PATCH /orgs/PSModule/properties/values \ + --field 'repository_names[]=' \ + --field 'properties[0][property_name]=Type' \ + --field 'properties[0][value]=Module' \ + --field 'properties[1][property_name]=SubscribeTo' \ + --field 'properties[1][value]=AGENTS.md' + ``` + +### Triggering the first run + +Once prerequisites are met, trigger the workflow manually: + +```bash +gh workflow run sync-files.yml --repo MSXOrg/Custo +``` + +Or navigate to **Actions → Sync Managed Files → Run workflow** in the GitHub UI. + +### What to expect + +- Each qualifying PSModule repo gets a PR titled `⚙️ [Maintenance]: Sync managed files` with `AGENTS.md` added/updated on branch `managed-files/update`. +- The workflow summary shows counts of PRs created/updated/skipped. +- Review one sample PR before merging the rest. If the content looks correct, merge the wave. + +### Rollback + +If the `AGENTS.md` content needs correction before merge: +1. Update `Repos/Module/AGENTS.md/AGENTS.md` in this repo and push. +2. Re-run the workflow — it will force-push the branch and the existing PR will update automatically. + +If PRs were already merged and the file needs to be removed, that must be done manually (no implicit delete by design). diff --git a/Repos/Module/AGENTS.md/AGENTS.md b/Repos/Module/AGENTS.md/AGENTS.md new file mode 100644 index 0000000..5550fe2 --- /dev/null +++ b/Repos/Module/AGENTS.md/AGENTS.md @@ -0,0 +1,27 @@ +# Agents + +> ⚙️ This file is **centrally managed** by [MSXOrg/Custo](https://github.com/MSXOrg/Custo). +> Do not edit it here — changes will be overwritten on the next sync. +> To propose updates, open a PR in MSXOrg/Custo. + +## Primary guidance + +This repository is a **PSModule PowerShell module**. All agent policy lives in two canonical sources: + +| Source | What it covers | +|--------|---------------| +| [PSModule/docs](https://github.com/PSModule/docs) | PSModule-specific standards: module structure, naming conventions, build pipeline, release process, and PowerShell coding style. Start here for any module task. | +| [MSXOrg/docs](https://github.com/MSXOrg/docs) | Cross-org foundations: vision, ways of working, issue/PR/commit conventions, and review etiquette. | + +Read PSModule/docs first, then MSXOrg/docs for anything not covered there. + +## Quick-start for an agent landing here + +1. Clone `PSModule/docs` as a bare repo with worktrees into `~/.msx/PSModule/`: + ``` + git clone --bare https://github.com/PSModule/docs ~/.msx/PSModule/docs + cd ~/.msx/PSModule/docs && git worktree add main main + ``` +2. Start at `~/.msx/PSModule/docs/main/` — read the top-level `index.md` and follow links into the relevant section. +3. Clone `MSXOrg/docs` the same way into `~/.msx/MSXOrg/docs` for cross-org context. +4. Apply micro-commits on every logical change and push each commit. diff --git a/config/targets.json b/config/targets.json new file mode 100644 index 0000000..d50ff12 --- /dev/null +++ b/config/targets.json @@ -0,0 +1,3 @@ +{ + "orgs": ["PSModule"] +} diff --git a/scripts/Sync-Files.ps1 b/scripts/Sync-Files.ps1 new file mode 100644 index 0000000..c02da09 --- /dev/null +++ b/scripts/Sync-Files.ps1 @@ -0,0 +1,424 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Syncs managed files from this repository to subscribing repositories across target organizations. + +.DESCRIPTION + This script: + 1. Reads target organizations from config/targets.json + 2. Creates Installation Access Token contexts for repo-level operations + 3. Discovers available file sets from the Repos/ directory structure + 4. Queries all organization repositories with their Type and SubscribeTo custom properties + 5. For each subscribing repository: + - Clones the repository + - Copies managed files from the appropriate file sets + - Detects changes using git + - Creates or updates a pull request if changes are detected + 6. Outputs a summary of actions taken + +.NOTES + Requires the GitHub PowerShell module and GitHub App authentication via GitHub-Script action. + Target organizations are read from config/targets.json in the repository root. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +# Track summary information +$script:Summary = @{ + TotalReposProcessed = 0 + PRsCreated = 0 + PRsUpdated = 0 + ReposAlreadyInSync = 0 + ReposSkipped = 0 + Errors = @() +} + +#region Helper Functions + +function Get-FileSets { + <# + .SYNOPSIS + Discovers available file sets from the Repos/ directory structure. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ReposPath + ) + + $fileSets = @{} + + if (-not (Test-Path $ReposPath)) { + throw "Repos directory not found at: $ReposPath" + } + + $typeDirs = Get-ChildItem -Path $ReposPath -Directory + $fileSetTable = @() + + foreach ($typeDir in $typeDirs) { + $typeName = $typeDir.Name + $fileSets[$typeName] = @{} + + $selectionDirs = Get-ChildItem -Path $typeDir.FullName -Directory + + foreach ($selectionDir in $selectionDirs) { + $selectionName = $selectionDir.Name + $files = Get-ChildItem -Path $selectionDir.FullName -File -Recurse + + $fileList = @() + foreach ($file in $files) { + $relativePath = $file.FullName.Substring($selectionDir.FullName.Length + 1) + $fileList += @{ + SourcePath = $file.FullName + RelativePath = $relativePath + } + } + + $fileSets[$typeName][$selectionName] = $fileList + $fileSetTable += [PSCustomObject]@{ + Type = $typeName + FileSet = $selectionName + Files = $fileList.Count + } + } + } + + $fileSetTable | Format-Table -AutoSize | Out-String + + return $fileSets +} + +function Get-SubscribingRepositories { + <# + .SYNOPSIS + Queries all organization repositories with their Type and SubscribeTo custom properties. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Owner, + + [Parameter(Mandatory)] + [object]$Context + ) + + $repos = Get-GitHubRepository -Owner $Owner -Context $Context + + $subscribingRepos = @() + + foreach ($repo in $repos) { + $customProps = $repo.CustomProperties + + if (-not $customProps) { + continue + } + + $type = ($customProps | Where-Object Name -EQ 'Type').Value + $subscribeTo = ($customProps | Where-Object Name -EQ 'SubscribeTo').Value + + if (-not $type -or -not $subscribeTo) { + continue + } + + if ($subscribeTo -is [string]) { + $subscribeTo = @($subscribeTo) + } + + if ($subscribeTo.Count -eq 0) { + continue + } + + $subscribingRepos += @{ + Name = $repo.Name + Owner = $repo.Owner.Login + FullName = $repo.FullName + Type = $type + SubscribeTo = $subscribeTo + DefaultBranch = $repo.DefaultBranch + } + } + + $subscribingRepos | ForEach-Object { + [PSCustomObject]@{ + Owner = $_.Owner + Repo = $_.Name + Type = $_.Type + SubscribeTo = $_.SubscribeTo -join ', ' + } + } | Format-Table -AutoSize | Out-String + + return $subscribingRepos +} + +function Sync-RepositoryFiles { + <# + .SYNOPSIS + Syncs files to a single repository. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Scope = 'Function', + Justification = 'Intended for logging in GitHub Actions runners.' + )] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Repository, + + [Parameter(Mandatory)] + [hashtable]$FileSets, + + [Parameter(Mandatory)] + [string]$TempPath, + + [Parameter(Mandatory)] + [string]$BranchName, + + [Parameter(Mandatory)] + [string]$CommitMessage, + + [Parameter(Mandatory)] + [string]$PRTitle, + + [Parameter(Mandatory)] + [string]$PRBody, + + [Parameter(Mandatory)] + [string]$PRLabel, + + [Parameter(Mandatory)] + [object]$Context + ) + + $repoFullName = $Repository.FullName + $owner = $Repository.Owner + $repoName = $Repository.Name + $type = $Repository.Type + $subscribeTo = $Repository.SubscribeTo + + $script:Summary.TotalReposProcessed++ + + # Validate before opening a log group — skipped repos stay quiet + if (-not $FileSets.ContainsKey($type)) { + Write-Host "⚠️ $repoFullName - Type folder '$type' not found, skipping" + $script:Summary.ReposSkipped++ + return + } + + $filesToSync = @() + foreach ($selection in $subscribeTo) { + if (-not $FileSets[$type].ContainsKey($selection)) { + Write-Host "⚠️ $repoFullName - Selection '$selection' not found under '$type'" + continue + } + $filesToSync += $FileSets[$type][$selection] + } + + if ($filesToSync.Count -eq 0) { + Write-Host "⚠️ $repoFullName - No matching files, skipping" + $script:Summary.ReposSkipped++ + return + } + + # All real work inside a log group + LogGroup "📦 $repoFullName" { + foreach ($selection in $subscribeTo) { + if ($FileSets[$type].ContainsKey($selection)) { + Write-Host " + $type/$selection ($($FileSets[$type][$selection].Count) files)" + } + } + + $clonePath = Join-Path $TempPath "clone-$repoName-$(Get-Random)" + New-Item -Path $clonePath -ItemType Directory -Force | Out-Null + + try { + $cloneUrl = "https://github.com/$repoFullName.git" + $gitCloneResult = git clone --depth 1 $cloneUrl $clonePath 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Git clone failed: $gitCloneResult" + } + + Push-Location $clonePath + try { + Set-GitHubGitConfig -Context $Context + + # Branch setup + $remoteBranches = git branch -r 2>&1 + if ($remoteBranches -match "origin/$BranchName") { + git fetch origin $BranchName 2>&1 | Out-Null + git checkout $BranchName 2>&1 | Out-Null + } else { + git checkout -b $BranchName 2>&1 | Out-Null + } + + # Copy files + foreach ($fileInfo in $filesToSync) { + $targetPath = Join-Path $clonePath $fileInfo.RelativePath + $targetDir = Split-Path $targetPath -Parent + if (-not (Test-Path $targetDir)) { + New-Item -Path $targetDir -ItemType Directory -Force | Out-Null + } + Copy-Item -Path $fileInfo.SourcePath -Destination $targetPath -Force + } + + # Detect changes + $status = git status --porcelain 2>&1 + if ([string]::IsNullOrWhiteSpace($status)) { + Write-Host '✅ Already in sync' + $script:Summary.ReposAlreadyInSync++ + return + } + + $status -split "`n" | ForEach-Object { Write-Host " $_" } + + # Commit and push + git add --all 2>&1 | Out-Null + git commit -m $CommitMessage 2>&1 | Out-Null + $pushResult = git push --force --set-upstream origin $BranchName 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Git push failed: $pushResult" + } + + # Create or update PR — no duplicate PRs + $existingPRs = (Invoke-GitHubAPI -Method GET -Endpoint "/repos/$owner/$repoName/pulls" -Body @{ + head = "${owner}:${BranchName}" + state = 'open' + } -Context $Context).Response + + if ($existingPRs.Count -gt 0) { + Write-Host "✅ Updated PR #$($existingPRs[0].number) - $($existingPRs[0].html_url)" + $script:Summary.PRsUpdated++ + } else { + $pr = (Invoke-GitHubAPI -Method POST -Endpoint "/repos/$owner/$repoName/pulls" -Body @{ + title = $PRTitle + head = $BranchName + base = $Repository.DefaultBranch + body = $PRBody + } -Context $Context).Response + + try { + Invoke-GitHubAPI -Method POST -Endpoint "/repos/$owner/$repoName/issues/$($pr.number)/labels" -Body @{ + labels = @($PRLabel) + } -Context $Context | Out-Null + } catch { + Write-Host "⚠️ Failed to add label: $_" + } + + Write-Host "✅ Created PR #$($pr.number) - $($pr.html_url)" + $script:Summary.PRsCreated++ + } + + } finally { + Pop-Location + } + + } catch { + Write-Host "❌ $_" + $script:Summary.Errors += "$repoFullName : $_" + } finally { + if (Test-Path $clonePath) { + Remove-Item -Path $clonePath -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} + +#endregion + +#region Main Script + +try { + LogGroup '🔑 Authenticate' { + $context = Connect-GitHubApp -PassThru + $context | Format-List | Out-String + } + + LogGroup '📂 Discover file sets' { + $reposPath = Join-Path $PSScriptRoot '../Repos' + $reposPath = Resolve-Path $reposPath + $fileSets = Get-FileSets -ReposPath $reposPath + } + + if ($fileSets.Count -eq 0) { + Write-Host '⚠️ No file sets found - nothing to do' + exit 0 + } + + # Load target organizations from config + LogGroup '📋 Load target organizations' { + $configPath = Join-Path $PSScriptRoot '../config/targets.json' + $configPath = Resolve-Path $configPath + $config = Get-Content $configPath -Raw | ConvertFrom-Json + $targetOrgs = $config.orgs + Write-Host "Target orgs: $($targetOrgs -join ', ')" + } + + $branchName = 'managed-files/update' + $commitMessage = 'chore: sync managed files' + $prTitle = '⚙️ [Maintenance]: Sync managed files' + $prLabel = 'NoRelease' + $prBody = @' +This pull request was automatically created by the [Custo](https://github.com/MSXOrg/Custo) workflow that keeps shared files in sync across the organization's repositories. + +The files in this PR are centrally managed. Any local changes to these files will be overwritten on the next sync. To propose changes, update the source files in the [MSXOrg/Custo](https://github.com/MSXOrg/Custo) repository instead. +'@ + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "custo-sync-$(Get-Random)" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + + try { + foreach ($org in $targetOrgs) { + LogGroup "🔍 Find subscribing repositories in $org" { + $subscribingRepos = Get-SubscribingRepositories -Owner $org -Context $context + Write-Host "Found $($subscribingRepos.Count) subscribing repositories in $org" + } + + if ($subscribingRepos.Count -eq 0) { + Write-Host "⚠️ No subscribing repositories found in $org - skipping" + continue + } + + foreach ($repo in $subscribingRepos) { + Sync-RepositoryFiles -Repository $repo ` + -FileSets $fileSets ` + -TempPath $tempPath ` + -BranchName $branchName ` + -CommitMessage $commitMessage ` + -PRTitle $prTitle ` + -PRBody $prBody ` + -PRLabel $prLabel ` + -Context $context + } + } + } finally { + if (Test-Path $tempPath) { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Summary + Write-Host '' + Write-Host '📊 Summary' + Write-Host " Processed: $($script:Summary.TotalReposProcessed)" + Write-Host " Created: $($script:Summary.PRsCreated)" + Write-Host " Updated: $($script:Summary.PRsUpdated)" + Write-Host " In sync: $($script:Summary.ReposAlreadyInSync)" + Write-Host " Skipped: $($script:Summary.ReposSkipped)" + + if ($script:Summary.Errors.Count -gt 0) { + Write-Host " Errors: $($script:Summary.Errors.Count)" + foreach ($err in $script:Summary.Errors) { + Write-Host " ❌ $err" + } + exit 1 + } + +} catch { + Write-Host "❌ Fatal: $_" + Write-Host $_.ScriptStackTrace + exit 1 +} + +#endregion