diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ced3460 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Lint and test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Run Pester tests + shell: pwsh + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck + $config = New-PesterConfiguration + $config.Run.Path = './tests' + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $config + + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Force -SkipPublisherCheck + $results = Invoke-ScriptAnalyzer -Path ./scripts -Recurse -Severity Error + if ($results) { + $results | Format-Table -AutoSize + throw "PSScriptAnalyzer reported $($results.Count) error-level finding(s)." + } + Write-Host 'PSScriptAnalyzer: no error-level findings.' diff --git a/.github/workflows/sync-files.yml b/.github/workflows/sync-files.yml new file mode 100644 index 0000000..c531a8f --- /dev/null +++ b/.github/workflows/sync-files.yml @@ -0,0 +1,26 @@ +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 + env: + CUSTO_ENTERPRISE_PAT: ${{ secrets.CUSTO_ENTERPRISE_PAT }} + with: + Script: ./scripts/Sync-Files.ps1 + ClientID: ${{ secrets.CUSTO_BOT_CLIENT_ID }} + PrivateKey: ${{ secrets.CUSTO_BOT_PRIVATE_KEY }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..71559e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Agents + +## Main directive + +Everything here is a work in progress and can be improved. If you find a problem, fix it if it's +small; otherwise register it as an issue in this repo. + +Read [MSXOrg/docs AGENTS.md](https://github.com/MSXOrg/docs/blob/main/AGENTS.md) first for +ecosystem-wide onboarding. This file only covers what's specific to Custo. + +## What this repo is + +Custo is the distribution runtime described in [README.md](README.md). Read that first for the +architecture and the source-of-truth separation between standards, initiative docs, and this +runtime. + +## Operator runbook: enabling live rollout + +The MVP file set (`Repos/Module/AGENTS.md/`) and workflow are in place, but the scheduled sync +cannot run against real repositories until an operator completes these steps: + +1. **Configure GitHub App credentials.** Add `CUSTO_BOT_CLIENT_ID` and `CUSTO_BOT_PRIVATE_KEY` + as repository secrets on `MSXOrg/Custo`, using the same `PSModule's Custo` GitHub App + (app id `1320343`, slug `psmodule-s-custo`) already installed on the `PSModule` organization. + If that app's credentials are not available, generate a new private key for it or provision an + equivalent app with `contents:write`, `pull_requests:write`, and + `repository_custom_properties:read` on target repositories, plus enterprise custom-properties + write/admin permission if enterprise schema sync is enabled. + For capabilities configured with `authMode: enterprise-pat`, also add + `CUSTO_ENTERPRISE_PAT` and grant it enterprise-admin scope. +2. **Confirm app installation scope.** Verify the app is installed on the `PSModule` organization + with access to the module repositories that should receive `AGENTS.md` + (`gh api orgs/PSModule/installations`). +3. **Set custom properties on target repositories.** Each subscribing module repository needs + `Type = Module` and `SubscribeTo` including `AGENTS.md` set at the repository level. + Custo now maintains allowed values at enterprise level from `Repos/` file sets via policy + documents in `PolicyEngine/Policies/{enterprise}/`. +4. **Dry-run via `workflow_dispatch`.** Trigger the `Sync Managed Files` workflow manually first + and review the summary before relying on the daily schedule. + +## Policy documents + +Policy configuration is declared in `PolicyEngine/` and executed in fixed layer order: +1. enterprise +2. organization +3. repository + +Current default enterprise mapping (`MSXOrg`): +- `PolicyEngine/Capabilities/enterprise/repo-custom-property.capability.json` +- `PolicyEngine/Capabilities/enterprise/repo-rulesets.capability.json` +- `PolicyEngine/Capabilities/organization/none.capability.json` +- `PolicyEngine/Capabilities/repository/file-subscription-service.capability.json` +- `PolicyEngine/Policies/MSXOrg/enterprise/repo-custom-property.policy.json` +- `PolicyEngine/Policies/MSXOrg/enterprise/repo-rulesets.policy.json` +- `PolicyEngine/Policies/MSXOrg/organization/_default/none.policy.json` +- `PolicyEngine/Policies/MSXOrg/repository/_default/file-subscription-service.policy.json` + +Until step 1 is complete, the workflow will fail at the authentication step +(`Connect-GitHub App`) — this is the current, expected blocker for this MVP. diff --git a/PolicyEngine/Capabilities/enterprise/repo-custom-property.capability.json b/PolicyEngine/Capabilities/enterprise/repo-custom-property.capability.json new file mode 100644 index 0000000..f711426 --- /dev/null +++ b/PolicyEngine/Capabilities/enterprise/repo-custom-property.capability.json @@ -0,0 +1,43 @@ +{ + "version": "1.0", + "name": "Enterprise repository custom-property schema", + "layer": "enterprise", + "capability": "repo-custom-property", + "authMode": "github-app", + "description": "Maintains enterprise-level repository custom-property definitions and allowed values.", + "configSchema": { + "required": ["enterprise"], + "properties": { + "enterprise": { + "type": "string", + "description": "Enterprise slug to maintain property definitions on." + }, + "typePropertyName": { + "type": "string", + "default": "Type", + "description": "Repository type property name." + }, + "subscriptionPropertyName": { + "type": "string", + "default": "SubscribeTo", + "description": "Managed file-subscription property name." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether repositories must explicitly set these property values." + }, + "valuesEditableBy": { + "type": "string", + "enum": ["org_actors", "org_and_repo_actors", null], + "default": "org_actors", + "description": "Who can edit property values." + }, + "requireExplicitValues": { + "type": "boolean", + "default": false, + "description": "Whether setting property values is mandatory." + } + } + } +} diff --git a/PolicyEngine/Capabilities/enterprise/repo-rulesets.capability.json b/PolicyEngine/Capabilities/enterprise/repo-rulesets.capability.json new file mode 100644 index 0000000..c4e5d22 --- /dev/null +++ b/PolicyEngine/Capabilities/enterprise/repo-rulesets.capability.json @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "name": "Enterprise repository rulesets", + "layer": "enterprise", + "capability": "repo-rulesets", + "authMode": "enterprise-pat", + "description": "Maintains enterprise-level repository rulesets.", + "configSchema": { + "required": ["enterprise", "rulesets"], + "properties": { + "enterprise": { + "type": "string", + "description": "Enterprise slug to maintain rulesets on." + }, + "rulesets": { + "type": "array", + "description": "Ruleset definitions mapped directly to GitHub enterprise ruleset payloads." + } + } + } +} diff --git a/PolicyEngine/Capabilities/organization/none.capability.json b/PolicyEngine/Capabilities/organization/none.capability.json new file mode 100644 index 0000000..2a6a3af --- /dev/null +++ b/PolicyEngine/Capabilities/organization/none.capability.json @@ -0,0 +1,8 @@ +{ + "version": "1.0", + "name": "Organization policy placeholder", + "layer": "organization", + "capability": "none", + "authMode": "github-app", + "description": "Placeholder capability for future organization-level policies." +} diff --git a/PolicyEngine/Capabilities/repository/file-subscription-service.capability.json b/PolicyEngine/Capabilities/repository/file-subscription-service.capability.json new file mode 100644 index 0000000..5dcfb95 --- /dev/null +++ b/PolicyEngine/Capabilities/repository/file-subscription-service.capability.json @@ -0,0 +1,8 @@ +{ + "version": "1.0", + "name": "Repository file subscription service", + "layer": "repository", + "capability": "file-subscription-service", + "authMode": "github-app", + "description": "Synchronizes managed files into subscribing repositories via pull requests." +} diff --git a/PolicyEngine/Policies/MSXOrg/enterprise/repo-custom-property.policy.json b/PolicyEngine/Policies/MSXOrg/enterprise/repo-custom-property.policy.json new file mode 100644 index 0000000..be7bf37 --- /dev/null +++ b/PolicyEngine/Policies/MSXOrg/enterprise/repo-custom-property.policy.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "name": "Enterprise repo custom property policy", + "layer": "enterprise", + "capability": "repo-custom-property", + "order": 10, + "enabled": true, + "config": { + "enterprise": "MSXOrg", + "typePropertyName": "Type", + "subscriptionPropertyName": "SubscribeTo", + "required": false, + "valuesEditableBy": "org_actors", + "requireExplicitValues": false + } +} diff --git a/PolicyEngine/Policies/MSXOrg/enterprise/repo-rulesets.policy.json b/PolicyEngine/Policies/MSXOrg/enterprise/repo-rulesets.policy.json new file mode 100644 index 0000000..2f461b0 --- /dev/null +++ b/PolicyEngine/Policies/MSXOrg/enterprise/repo-rulesets.policy.json @@ -0,0 +1,66 @@ +{ + "version": "1.0", + "name": "Enterprise repo ruleset policy", + "layer": "enterprise", + "capability": "repo-rulesets", + "order": 20, + "enabled": true, + "config": { + "enterprise": "MSXOrg", + "rulesets": [ + { + "name": "Custo default branch policy", + "target": "branch", + "enforcement": "disabled", + "conditions": { + "organization_name_and_repository_property": { + "organization_name": { + "include": [ + "~ALL" + ], + "exclude": [] + }, + "repository_property": { + "include": [ + { + "name": "Type", + "property_values": [ + "Module" + ], + "source": "custom" + } + ], + "exclude": [] + }, + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + } + }, + "rules": [ + { + "type": "required_linear_history" + }, + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + } + ] + } + ] + } +} diff --git a/PolicyEngine/Policies/MSXOrg/organization/_default/none.policy.json b/PolicyEngine/Policies/MSXOrg/organization/_default/none.policy.json new file mode 100644 index 0000000..7a06f89 --- /dev/null +++ b/PolicyEngine/Policies/MSXOrg/organization/_default/none.policy.json @@ -0,0 +1,9 @@ +{ + "version": "1.0", + "name": "Organization policy placeholder", + "layer": "organization", + "capability": "none", + "order": 10, + "enabled": true, + "config": {} +} diff --git a/PolicyEngine/Policies/MSXOrg/repository/_default/file-subscription-service.policy.json b/PolicyEngine/Policies/MSXOrg/repository/_default/file-subscription-service.policy.json new file mode 100644 index 0000000..a47a4cc --- /dev/null +++ b/PolicyEngine/Policies/MSXOrg/repository/_default/file-subscription-service.policy.json @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "name": "Repository file subscription service", + "layer": "repository", + "capability": "file-subscription-service", + "order": 10, + "enabled": true, + "config": { + "branchName": "managed-files/update", + "commitMessage": "chore: sync managed files", + "prTitle": "⚙️ [Maintenance]: Sync managed files", + "prLabel": "NoRelease" + } +} diff --git a/PolicyEngine/schemas/capability.schema.json b/PolicyEngine/schemas/capability.schema.json new file mode 100644 index 0000000..2ab289c --- /dev/null +++ b/PolicyEngine/schemas/capability.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://msxorg.github.io/custo/schemas/capability.schema.json", + "title": "Custo capability document", + "description": "Defines what a policy capability does and how it authenticates. One document per Layer.Capability.", + "type": "object", + "required": ["version", "name", "layer", "capability", "authMode"], + "properties": { + "version": { + "type": "string", + "description": "Document schema version." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable capability name." + }, + "layer": { + "type": "string", + "enum": ["enterprise", "organization", "repository"], + "description": "Policy layer this capability runs at." + }, + "capability": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Capability identifier (kebab-case) unique within its layer." + }, + "authMode": { + "type": "string", + "enum": ["github-app", "enterprise-pat"], + "description": "Explicit authentication mode. github-app uses the installation token; enterprise-pat uses CUSTO_ENTERPRISE_PAT for enterprise-admin endpoints the app cannot reach." + }, + "description": { + "type": "string" + }, + "configSchema": { + "type": "object", + "description": "Optional inline description of the config object accepted by matching policy documents." + } + }, + "additionalProperties": true +} diff --git a/PolicyEngine/schemas/policy.schema.json b/PolicyEngine/schemas/policy.schema.json new file mode 100644 index 0000000..4b12ed9 --- /dev/null +++ b/PolicyEngine/schemas/policy.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://msxorg.github.io/custo/schemas/policy.schema.json", + "title": "Custo policy document", + "description": "Configures a capability for a specific enterprise/layer. Placed under PolicyEngine/Policies/{enterprise}/{layer}/...", + "type": "object", + "required": ["version", "name", "layer", "capability", "enabled"], + "properties": { + "version": { + "type": "string", + "description": "Document schema version." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable policy name." + }, + "layer": { + "type": "string", + "enum": ["enterprise", "organization", "repository"], + "description": "Policy layer. Must match the capability's layer and the folder it lives in." + }, + "capability": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Capability this policy configures. Must exist in the capability catalog for this layer." + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "Execution order within the layer (ascending). Defaults to 1000 when omitted." + }, + "enabled": { + "type": "boolean", + "description": "Whether this policy is applied. Disabled policies are loaded and skipped." + }, + "config": { + "type": "object", + "description": "Capability-specific configuration payload." + } + }, + "additionalProperties": true +} diff --git a/README.md b/README.md index f1bda8f..21fdba8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,130 @@ # Custo -Central managed-file distribution and compliance orchestration for MSX initiatives + +Custo is the central managed-file distribution engine for MSX initiatives. It syncs shared, +centrally-owned files into subscribing repositories by pull request, so every repository +inherits the same governance, linting, and agent-context files without copy-pasting them by hand. + +## Source of truth separation + +- **Standards** — *what* every initiative must define and *why* — live in + [`MSXOrg/docs`](https://github.com/MSXOrg/docs), specifically the + [Organization Standard](https://msxorg.github.io/docs/Ways-of-Working/Organization-Standard/). +- **Initiative implementation guidance** — how a specific initiative applies those standards — + lives in each initiative's own docs repository, for example + [`PSModule/docs`](https://github.com/PSModule/docs). +- **Distribution runtime** — *how* managed files are actually delivered to repositories — lives + here, in Custo. + +Custo does not decide what files initiatives should manage. It only distributes what each +initiative's file sets declare, following the [managed files contract](https://msxorg.github.io/docs/Ways-of-Working/Organization-Standard/#managed-files) +defined in MSXOrg/docs. + +Custo replaces [`PSModule/Distributor`](https://github.com/PSModule/Distributor) as the runtime +engine for the PSModule initiative, generalized so other initiatives can reuse it instead of +building their own distributor from scratch. + +## How it works + +File sets are organized by repository type and selection: + +```text +Repos/{Type}/{Selection}/ +``` + +- **Type** — groups repositories by kind (`Module`, `Action`, `Template`, `Workflow`, ...). +- **Selection** — an individual file set repositories opt into via the `SubscribeTo` custom + property. Each selection folder mirrors the root of a target repository. + +Target discovery scope is declared in [`config/targets.json`](config/targets.json), not hardcoded +in the sync script. The default scope is `all-access`, which enumerates the organizations the +current GitHub App installation can see (`GET /app/installations`, organization accounts only) and +then the repositories within each. User-owned repositories are not targeted. This keeps Custo +org-agnostic and lets one runtime process every organization and repository it can access. + +Policy behavior is defined by JSON documents under [`PolicyEngine/`](PolicyEngine/), not embedded +in `targets.json`. This separates **capabilities** from **policy configuration**: + +- `PolicyEngine/Capabilities/{layer}/*.capability.json` defines what each capability does. +- `PolicyEngine/Policies/{enterprise}/{layer}/...` defines how that capability is configured for + a specific enterprise. Layer policies can be placed at `_default` plus increasingly specific + paths (`organization/{org}`, `repository/{org}/_default`, `repository/{org}/{repo}`). + +The [`scripts/Sync-Files.ps1`](scripts/Sync-Files.ps1) script, run by the +[`Sync Managed Files`](.github/workflows/sync-files.yml) workflow: + +1. Reads repository discovery scope from `config/targets.json`. +2. Discovers file sets under `Repos/`. +3. Loads capability and policy documents from `PolicyEngine/`. +4. Applies policy controls in order: **Enterprise** first, then **Organization**, then **Repository**. +5. Runs enterprise policy capabilities such as: + - `repo-custom-property` (maintain `Type` and `SubscribeTo` enterprise property definitions) + - `repo-rulesets` (maintain enterprise repository rulesets; policy payload controls conditions/rules) +6. Discovers subscribing repositories from all accessible repositories (or explicit organizations + when configured) and reads their `Type` and `SubscribeTo` custom properties. +7. Executes repository capability `file-subscription-service`, cloning repositories, syncing files, + and opening/updating + `managed-files/update` pull request when changes are detected. + +## MVP rollout scope + +The first managed resource is **`AGENTS.md`** for PowerShell module repositories +(`Repos/Module/AGENTS.md/AGENTS.md`). The file is a thin pointer into the central docs rather than +a duplicated process document, matching the pattern already used by +[`PSModule/Template-PSModule`](https://github.com/PSModule/Template-PSModule) and +[`PSModule/memory`](https://github.com/PSModule/memory). + +Additional file sets and rollout targets are added incrementally after this MVP is proven. + +## Required secrets + +The sync workflow authenticates as a GitHub App. Enterprise policy capabilities choose auth +explicitly via `PolicyEngine/Capabilities`: +- `authMode: github-app` uses the GitHub App installation token. +- `authMode: enterprise-pat` uses `CUSTO_ENTERPRISE_PAT` for endpoints requiring enterprise PAT scope. + +For repository sync plus enterprise policy maintenance, configure: + +- `contents:write` +- `pull_requests:write` +- `repository_custom_properties:read` +- enterprise custom-properties write/admin access (for `/enterprises/{enterprise}/properties/schema`) + +Configure these repository secrets before enabling the scheduled sync: + +- `CUSTO_BOT_CLIENT_ID` +- `CUSTO_BOT_PRIVATE_KEY` +- `CUSTO_ENTERPRISE_PAT` (required for any capability configured with `authMode: enterprise-pat`, e.g. enterprise rulesets, with `admin:enterprise`) + +Default enterprise policy configs live at: + +- `PolicyEngine/Policies/MSXOrg/enterprise/repo-custom-property.policy.json` +- `PolicyEngine/Policies/MSXOrg/enterprise/repo-rulesets.policy.json` +- `PolicyEngine/Policies/MSXOrg/organization/_default/none.policy.json` +- `PolicyEngine/Policies/MSXOrg/repository/_default/file-subscription-service.policy.json` + +### `CUSTO_ENTERPRISE_PAT` blast radius + +`admin:enterprise` is a high-privilege scope. Treat the PAT as a sensitive credential and reduce +its blast radius: + +- Scope the workflow to a protected [environment](https://docs.github.com/actions/deployment/targeting-different-environments) + with required reviewers, so the PAT is only exposed on approved runs. +- Restrict who can trigger `workflow_dispatch` and limit branches that can run the workflow. +- Prefer a dedicated bot identity and rotate the token regularly. +- Only enable capabilities with `authMode: enterprise-pat` when their enterprise API genuinely + cannot be reached by the GitHub App. + +## Safety and validation + +- Run the workflow (or `scripts/Sync-Files.ps1`) with `-WhatIf` first: every capability evaluates + current vs desired state and prints a diff without writing. +- A Markdown run report (orgs/repos processed, PRs, errors) is written to the job summary. +- `tests/` holds Pester suites for the diff, policy-loading and routing logic, plus conformance + checks for every capability/policy document. The [`CI`](.github/workflows/ci.yml) workflow runs + them with PSScriptAnalyzer on every push and pull request. + +See [`AGENTS.md`](AGENTS.md) for operator runbook steps and current rollout blockers. + +## License + +MIT License — see [LICENSE](LICENSE). diff --git a/Repos/Module/AGENTS.md/AGENTS.md b/Repos/Module/AGENTS.md/AGENTS.md new file mode 100644 index 0000000..0b8c95a --- /dev/null +++ b/Repos/Module/AGENTS.md/AGENTS.md @@ -0,0 +1,22 @@ +# Agents + +## Main directive + +Everything here is a work in progress and can be improved. If you find a problem, fix it if it's +small; otherwise register it as an issue in this repo. + +## Ecosystem context + +Read [MSXOrg/docs AGENTS.md](https://github.com/MSXOrg/docs/blob/main/AGENTS.md) for the +ecosystem-wide onboarding steps shared by every MSX repository. + +Read [PSModule/docs](https://github.com/PSModule/docs) for how the PSModule initiative applies +those standards, starting with the +[PowerShell module standard](https://msxorg.github.io/docs/) and +[repository defaults](https://github.com/PSModule/docs/blob/main/src/docs/Modules/Repository-Defaults.md). + +## This file is managed + +This `AGENTS.md` is distributed by [`MSXOrg/Custo`](https://github.com/MSXOrg/Custo). Local edits +are overwritten on the next sync. To change its content, update the source file in Custo's +`Repos/Module/AGENTS.md/AGENTS.md` instead. diff --git a/config/targets.json b/config/targets.json new file mode 100644 index 0000000..1417be3 --- /dev/null +++ b/config/targets.json @@ -0,0 +1,8 @@ +{ + "$schema": "./targets.schema.json", + "scope": "all-access", + "policy": { + "rootPath": "../PolicyEngine", + "enterprise": "MSXOrg" + } +} diff --git a/config/targets.schema.json b/config/targets.schema.json new file mode 100644 index 0000000..a234306 --- /dev/null +++ b/config/targets.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Custo target discovery", + "description": "Repository discovery scope and policy engine paths for Sync Managed Files.", + "type": "object", + "required": ["scope"], + "properties": { + "scope": { + "type": "string", + "enum": ["all-access", "organizations"], + "description": "all-access discovers all repositories visible to the current app installation token; organizations restricts discovery to listed organizations." + }, + "organizations": { + "type": "array", + "description": "GitHub organization logins to scan when scope is organizations.", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "policy": { + "type": "object", + "description": "Policy engine folder mapping and enterprise selection.", + "properties": { + "rootPath": { + "type": "string", + "minLength": 1, + "description": "Relative or absolute root path that contains Capabilities/ and Policies/." + }, + "enterprise": { + "type": "string", + "minLength": 1, + "description": "Enterprise key used under Policies/{enterprise}/." + } + }, + "required": ["enterprise"], + "additionalProperties": false + } + }, + "allOf": [ + { + "if": { + "properties": { "scope": { "const": "organizations" } } + }, + "then": { + "required": ["organizations"], + "properties": { + "organizations": { + "minItems": 1 + } + } + } + } + ], + "additionalProperties": false +} diff --git a/scripts/Sync-Files.ps1 b/scripts/Sync-Files.ps1 new file mode 100644 index 0000000..eb244a8 --- /dev/null +++ b/scripts/Sync-Files.ps1 @@ -0,0 +1,1096 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Policy-driven managed file and governance sync runtime. + +.DESCRIPTION + Execution model: + 0) GitHub App bootstrap auth (ClientID + PrivateKey) + 1) Enterprise layer: execute enterprise policies in order + 2) Organization layer: for each org installation, execute org policies in order + 3) Repository layer: for each repo in each org, execute repo policies in order + + Policies are loaded dynamically from PolicyEngine/Policies/{enterprise}/... + Capabilities are loaded dynamically from PolicyEngine/Capabilities/... + + Enterprise policy capabilities declare auth mode explicitly: + - github-app: use GitHub App installation context + - enterprise-pat: use CUSTO_ENTERPRISE_PAT for enterprise-admin endpoints +#> + +[CmdletBinding()] +param( + [switch]$WhatIf +) + +$ErrorActionPreference = 'Stop' + +$script:Summary = @{ + OrgsProcessed = 0 + OrgsFailed = 0 + TotalReposProcessed = 0 + PRsCreated = 0 + PRsUpdated = 0 + ReposAlreadyInSync = 0 + ReposSkipped = 0 + Errors = @() +} + +function Get-TargetScope { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ConfigPath + ) + + if (-not (Test-Path $ConfigPath)) { + throw "Targets config not found at: $ConfigPath" + } + + $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json + if (-not $config.scope) { + throw "Missing required 'scope' in: $ConfigPath" + } + + if ($config.scope -eq 'organizations') { + if (-not $config.organizations -or $config.organizations.Count -eq 0) { + throw "Scope 'organizations' requires a non-empty organizations list in: $ConfigPath" + } + } elseif ($config.scope -ne 'all-access') { + throw "Unsupported scope '$($config.scope)' in: $ConfigPath" + } + + if (-not $config.policy) { + throw "Missing required 'policy' section in: $ConfigPath" + } + + if (-not $config.policy.enterprise) { + throw "Config policy section requires 'enterprise'." + } + + return $config +} + +function Get-FileSets { + [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 + 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 + } + } + + return $fileSets +} + +function Get-CapabilityCatalog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$CapabilityPath + ) + + if (-not (Test-Path $CapabilityPath)) { + throw "Capability path not found at: $CapabilityPath" + } + + $capabilityFiles = Get-ChildItem -Path $CapabilityPath -File -Recurse -Filter '*.capability.json' | Sort-Object FullName + if ($capabilityFiles.Count -eq 0) { + throw "No capability documents found in: $CapabilityPath" + } + + $catalog = @{} + foreach ($file in $capabilityFiles) { + $doc = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json -AsHashtable + if (-not $doc.layer -or -not $doc.capability) { + throw "Capability '$($file.Name)' must define both layer and capability." + } + + $layer = $doc.layer.ToLowerInvariant() + $capability = $doc.capability.ToLowerInvariant() + $key = "$layer.$capability" + + if ($catalog.ContainsKey($key)) { + throw "Duplicate capability definition for '$key': $($file.FullName)" + } + + $doc.layer = $layer + $doc.capability = $capability + $doc.sourceFile = $file.FullName + $catalog[$key] = $doc + } + + return $catalog +} + +function Get-PolicyDocumentsFromPaths { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Paths, + + [Parameter(Mandatory)] + [hashtable]$CapabilityCatalog, + + [Parameter(Mandatory)] + [string]$Layer + ) + + $layerOrder = @{ + enterprise = 1 + organization = 2 + repository = 3 + } + + $documents = @() + foreach ($path in $Paths) { + if (-not (Test-Path $path)) { + continue + } + + $files = Get-ChildItem -Path $path -File -Filter '*.policy.json' | Sort-Object FullName + foreach ($file in $files) { + $doc = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json -AsHashtable + if (-not $doc.layer -or -not $doc.capability -or -not $doc.ContainsKey('enabled')) { + throw "Policy '$($file.Name)' must include layer, capability, and enabled." + } + + $doc.layer = $doc.layer.ToLowerInvariant() + $doc.capability = $doc.capability.ToLowerInvariant() + $doc.sourceFile = $file.FullName + if ($doc.layer -ne $Layer) { + continue + } + + $capabilityKey = "$($doc.layer).$($doc.capability)" + if (-not $CapabilityCatalog.ContainsKey($capabilityKey)) { + throw "Policy '$($file.Name)' references unsupported capability '$capabilityKey'." + } + + $documents += $doc + } + } + + $documents | Sort-Object @{ Expression = { if ($_.ContainsKey('order')) { [int]$_.order } else { 1000 } } }, @{ Expression = { $layerOrder[$_.layer] } }, @{ Expression = { $_.capability } }, @{ Expression = { $_.sourceFile } } +} + +function Resolve-PolicyLayerPaths { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PolicyRoot, + + [Parameter(Mandatory)] + [string]$Enterprise, + + [Parameter(Mandatory)] + [ValidateSet('enterprise', 'organization', 'repository')] + [string]$Layer, + + [string]$Organization, + [string]$Repository + ) + + $base = Join-Path (Join-Path $PolicyRoot 'Policies') $Enterprise + $paths = @() + + switch ($Layer) { + 'enterprise' { + $paths += (Join-Path $base 'enterprise') + } + 'organization' { + $paths += (Join-Path $base 'organization') + $paths += (Join-Path (Join-Path $base 'organization') '_default') + if ($Organization) { + $paths += (Join-Path (Join-Path $base 'organization') $Organization) + } + } + 'repository' { + $paths += (Join-Path $base 'repository') + $paths += (Join-Path (Join-Path $base 'repository') '_default') + if ($Organization) { + $orgBase = Join-Path (Join-Path $base 'repository') $Organization + $paths += (Join-Path $orgBase '_default') + if ($Repository) { + $paths += (Join-Path $orgBase $Repository) + } + } + } + } + + return $paths +} + +function ConvertTo-CanonicalObject { + [CmdletBinding()] + param( + $InputObject + ) + + if ($null -eq $InputObject) { return $null } + + if ($InputObject -is [string] -or $InputObject -is [ValueType]) { + return $InputObject + } + + if ($InputObject -is [System.Collections.IDictionary]) { + $ordered = [ordered]@{} + foreach ($key in ($InputObject.Keys | Sort-Object)) { + $ordered[[string]$key] = ConvertTo-CanonicalObject -InputObject $InputObject[$key] + } + return $ordered + } + + if ($InputObject -is [System.Management.Automation.PSCustomObject]) { + $ordered = [ordered]@{} + foreach ($prop in ($InputObject.PSObject.Properties.Name | Sort-Object)) { + $ordered[$prop] = ConvertTo-CanonicalObject -InputObject $InputObject.$prop + } + return $ordered + } + + if ($InputObject -is [System.Collections.IEnumerable]) { + $list = @() + foreach ($item in $InputObject) { + $list += , (ConvertTo-CanonicalObject -InputObject $item) + } + return , $list + } + + return $InputObject +} + +function Test-CanonicalSubset { + <# + Returns $true when every value declared in $Subset is present and equal in + $Superset. Both inputs must already be canonicalized. A $null desired value is + satisfied when the key is absent or already null, so server-added fields never + register as drift. + #> + [CmdletBinding()] + param( + $Superset, + $Subset + ) + + if ($Subset -is [System.Collections.IDictionary]) { + if (-not ($Superset -is [System.Collections.IDictionary])) { return $false } + foreach ($key in $Subset.Keys) { + $desiredValue = $Subset[$key] + $hasKey = $Superset.Contains($key) + $currentValue = if ($hasKey) { $Superset[$key] } else { $null } + + if ($null -eq $desiredValue) { + if ($hasKey -and $null -ne $currentValue) { return $false } + continue + } + + if (-not $hasKey) { return $false } + if (-not (Test-CanonicalSubset -Superset $currentValue -Subset $desiredValue)) { return $false } + } + return $true + } + + if ($Subset -is [array]) { + if (-not ($Superset -is [array])) { return $false } + if ($Superset.Count -ne $Subset.Count) { return $false } + for ($i = 0; $i -lt $Subset.Count; $i++) { + if (-not (Test-CanonicalSubset -Superset $Superset[$i] -Subset $Subset[$i])) { return $false } + } + return $true + } + + return ($Superset -eq $Subset) +} + +function ConvertTo-NormalizedJson { + [CmdletBinding()] + param( + $InputObject + ) + + return (ConvertTo-CanonicalObject -InputObject $InputObject | ConvertTo-Json -Depth 50) +} + +function Write-ConfigDiff { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Title, + + $Current, + $Desired + ) + + $canonicalCurrent = ConvertTo-CanonicalObject -InputObject $Current + $canonicalDesired = ConvertTo-CanonicalObject -InputObject $Desired + + if (Test-CanonicalSubset -Superset $canonicalCurrent -Subset $canonicalDesired) { + Write-Host "✅ $Title already matches desired state" + return $false + } + + Write-Host "🔎 Diff for $Title (< current / > desired)" + $currentLines = ($canonicalCurrent | ConvertTo-Json -Depth 50) -split "`r?`n" + $desiredLines = ($canonicalDesired | ConvertTo-Json -Depth 50) -split "`r?`n" + $diff = Compare-Object -ReferenceObject $currentLines -DifferenceObject $desiredLines + foreach ($line in $diff) { + Write-Host " $($line.SideIndicator) $($line.InputObject)" + } + return $true +} + +function Write-RunReport { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', Scope = 'Function', + Justification = 'Intended for logging in GitHub Actions runners.' + )] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Summary, + + [switch]$WhatIf + ) + + $mode = if ($WhatIf) { 'plan (WhatIf — no writes)' } else { 'apply' } + $lines = @( + "## Custo sync report — $mode", + '', + '| Metric | Count |', + '| --- | --- |', + "| Orgs processed | $($Summary.OrgsProcessed) |", + "| Orgs failed | $($Summary.OrgsFailed) |", + "| Repos processed | $($Summary.TotalReposProcessed) |", + "| PRs created | $($Summary.PRsCreated) |", + "| PRs updated | $($Summary.PRsUpdated) |", + "| Already in sync | $($Summary.ReposAlreadyInSync) |", + "| Repos skipped | $($Summary.ReposSkipped) |", + "| Errors | $($Summary.Errors.Count) |" + ) + + if ($Summary.Errors.Count -gt 0) { + $lines += '' + $lines += '### Errors' + foreach ($err in $Summary.Errors) { + $lines += "- $err" + } + } + + $report = $lines -join "`n" + + if ($env:GITHUB_STEP_SUMMARY) { + $report | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } else { + Write-Host $report + } +} + +function Invoke-EnterprisePolicyApi { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] + [string]$Method, + + [Parameter(Mandatory)] + [string]$ApiEndpoint, + + [object]$Body, + + [Parameter(Mandatory)] + [ValidateSet('github-app', 'enterprise-pat')] + [string]$AuthMode, + + [Parameter(Mandatory)] + [object]$Context + ) + + switch ($AuthMode) { + 'github-app' { + return (Invoke-GitHubAPI -Method $Method -ApiEndpoint $ApiEndpoint -Body $Body -Context $Context).Response + } + 'enterprise-pat' { + $enterprisePat = $env:CUSTO_ENTERPRISE_PAT + if ([string]::IsNullOrWhiteSpace($enterprisePat)) { + throw "CUSTO_ENTERPRISE_PAT is required for auth mode 'enterprise-pat' ($ApiEndpoint)." + } + + $headers = @{ + Authorization = "Bearer $enterprisePat" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $uri = "https://api.github.com$ApiEndpoint" + $invokeArgs = @{ + Method = $Method + Uri = $uri + Headers = $headers + } + + if ($null -ne $Body) { + $invokeArgs.ContentType = 'application/json' + $invokeArgs.Body = $Body | ConvertTo-Json -Depth 50 + } + + return Invoke-RestMethod @invokeArgs + } + } +} + +function Get-OrganizationContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Organization + ) + + return (Connect-GitHubApp -Owner $Organization -PassThru) +} + +function Get-TargetOrganizations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject]$TargetScope, + + [Parameter(Mandatory)] + [object]$RootContext + ) + + if ($TargetScope.scope -eq 'organizations') { + return @($TargetScope.organizations | Sort-Object -Unique) + } + + $installations = @((Invoke-GitHubAPI -Method GET -ApiEndpoint '/app/installations' -Context $RootContext).Response) + $orgs = $installations | + Where-Object { $_.account.type -eq 'Organization' } | + ForEach-Object { $_.account.login } | + Sort-Object -Unique + + return @($orgs) +} + +function Get-RepositoryCustomProperties { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Owner, + + [Parameter(Mandatory)] + [string]$Repo, + + [Parameter(Mandatory)] + [object]$Context + ) + + return @((Invoke-GitHubAPI -Method GET -ApiEndpoint "/repos/$Owner/$Repo/properties/values" -Context $Context).Response) +} + +function Sync-EnterpriseCustomPropertySchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Enterprise, + + [Parameter(Mandatory)] + [hashtable]$FileSets, + + [Parameter(Mandatory)] + [object]$Context, + + [string]$TypePropertyName = 'Type', + [string]$SubscriptionPropertyName = 'SubscribeTo', + [bool]$Required = $false, + [object]$ValuesEditableBy = 'org_actors', + [bool]$RequireExplicitValues = $false, + [Parameter(Mandatory)] + [ValidateSet('github-app', 'enterprise-pat')] + [string]$AuthMode, + [switch]$WhatIf + ) + + $typeValues = @($FileSets.Keys | Sort-Object -Unique) + if ($typeValues.Count -eq 0) { + throw 'Cannot sync custom-property schema: no repository types discovered in Repos/.' + } + + $subscriptionValues = @($FileSets.Values | ForEach-Object { $_.Keys } | Sort-Object -Unique) + if ($subscriptionValues.Count -eq 0) { + throw 'Cannot sync custom-property schema: no file-set selections discovered in Repos/.' + } + + $desiredType = @{ + value_type = 'single_select' + required = $Required + default_value = $null + description = 'Repository type used by Custo managed-file distribution.' + allowed_values = $typeValues + values_editable_by = $ValuesEditableBy + require_explicit_values = $RequireExplicitValues + } + + $desiredSubscription = @{ + value_type = 'multi_select' + required = $Required + default_value = $null + description = 'Managed file sets the repository subscribes to from Custo.' + allowed_values = $subscriptionValues + values_editable_by = $ValuesEditableBy + require_explicit_values = $RequireExplicitValues + } + + $currentType = $null + $currentSubscription = $null + try { + $currentType = Invoke-EnterprisePolicyApi -Method GET -ApiEndpoint "/enterprises/$Enterprise/properties/schema/$TypePropertyName" -AuthMode $AuthMode -Context $Context + } catch { + Write-Host "ℹ️ Could not read current '$TypePropertyName' definition (treating as absent): $_" + } + try { + $currentSubscription = Invoke-EnterprisePolicyApi -Method GET -ApiEndpoint "/enterprises/$Enterprise/properties/schema/$SubscriptionPropertyName" -AuthMode $AuthMode -Context $Context + } catch { + Write-Host "ℹ️ Could not read current '$SubscriptionPropertyName' definition (treating as absent): $_" + } + + $typeChanged = Write-ConfigDiff -Title "enterprise property '$TypePropertyName'" -Current $currentType -Desired $desiredType + $subscriptionChanged = Write-ConfigDiff -Title "enterprise property '$SubscriptionPropertyName'" -Current $currentSubscription -Desired $desiredSubscription + + if ($WhatIf) { + Write-Host "WhatIf: skipping enterprise property updates" + return + } + + if ($typeChanged) { + Invoke-EnterprisePolicyApi -Method PUT -ApiEndpoint "/enterprises/$Enterprise/properties/schema/$TypePropertyName" -Body $desiredType -AuthMode $AuthMode -Context $Context | Out-Null + Write-Host "✅ Updated enterprise property '$TypePropertyName'" + } + + if ($subscriptionChanged) { + Invoke-EnterprisePolicyApi -Method PUT -ApiEndpoint "/enterprises/$Enterprise/properties/schema/$SubscriptionPropertyName" -Body $desiredSubscription -AuthMode $AuthMode -Context $Context | Out-Null + Write-Host "✅ Updated enterprise property '$SubscriptionPropertyName'" + } +} + +function Sync-EnterpriseRulesets { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Enterprise, + + [Parameter(Mandatory)] + [object[]]$Rulesets, + + [Parameter(Mandatory)] + [object]$Context, + + [Parameter(Mandatory)] + [ValidateSet('github-app', 'enterprise-pat')] + [string]$AuthMode, + + [switch]$WhatIf + ) + + if (-not $Rulesets -or $Rulesets.Count -eq 0) { + Write-Host "ℹ️ No enterprise rulesets declared for '$Enterprise'" + return + } + + $existingRulesets = @( + Invoke-EnterprisePolicyApi -Method GET -ApiEndpoint "/enterprises/$Enterprise/rulesets" -AuthMode $AuthMode -Context $Context + ) + + $existingByName = @{} + foreach ($existing in $existingRulesets) { + if ($existing.name) { + $existingByName[$existing.name] = $existing + } + } + + foreach ($ruleset in $Rulesets) { + if (-not $ruleset.name) { + throw "Enterprise ruleset entry is missing required field 'name'." + } + + $desired = @{} + foreach ($key in $ruleset.Keys) { + if ($key -notin @('id', 'source', 'source_type', 'created_at', 'updated_at', 'node_id', '_links', 'current_user_can_bypass')) { + $desired[$key] = $ruleset[$key] + } + } + + $currentComparable = $null + $endpoint = "/enterprises/$Enterprise/rulesets" + $method = 'POST' + if ($existingByName.ContainsKey($ruleset.name)) { + $existing = $existingByName[$ruleset.name] + $currentComparable = @{} + foreach ($key in $desired.Keys) { + if ($existing.PSObject.Properties.Name -contains $key) { + $currentComparable[$key] = $existing.$key + } + } + $endpoint = "/enterprises/$Enterprise/rulesets/$($existing.id)" + $method = 'PUT' + } + + $changed = Write-ConfigDiff -Title "enterprise ruleset '$($ruleset.name)'" -Current $currentComparable -Desired $desired + if (-not $changed) { + continue + } + + if ($WhatIf) { + Write-Host "WhatIf: skipping ruleset write for '$($ruleset.name)'" + continue + } + + Invoke-EnterprisePolicyApi -Method $method -ApiEndpoint $endpoint -Body $desired -AuthMode $AuthMode -Context $Context | Out-Null + Write-Host "✅ Upserted enterprise ruleset '$($ruleset.name)'" + } +} + +function Get-RepositoriesForOrganization { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Organization, + + [Parameter(Mandatory)] + [object]$Context + ) + + return @((Get-GitHubRepository -Owner $Organization -Context $Context)) +} + +function Sync-RepositoryFileSubscription { + [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, + + [switch]$WhatIf + ) + + $repoFullName = $Repository.FullName + $owner = $Repository.Owner + $repoName = $Repository.Name + $type = $Repository.Type + $subscribeTo = $Repository.SubscribeTo + + $script:Summary.TotalReposProcessed++ + + 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 + } + + LogGroup "📦 $repoFullName" { + $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 + + $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 + } + + 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 + } + + $status = git status --porcelain 2>&1 + if ([string]::IsNullOrWhiteSpace($status)) { + Write-Host '✅ Already in sync' + $script:Summary.ReposAlreadyInSync++ + return + } + + Write-Host '🔎 Repository file diff' + $status -split "`n" | ForEach-Object { Write-Host " $_" } + + if ($WhatIf) { + Write-Host "WhatIf: skipping commit/push/PR for $repoFullName" + return + } + + 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" + } + + $existingPRs = (Invoke-GitHubAPI -Method GET -ApiEndpoint "/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 -ApiEndpoint "/repos/$owner/$repoName/pulls" -Body @{ + title = $PRTitle + head = $BranchName + base = $Repository.DefaultBranch + body = $PRBody + } -Context $Context).Response + + try { + Invoke-GitHubAPI -Method POST -ApiEndpoint "/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 + } + } + } +} + +function Invoke-PoliciesForScope { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Policies, + + [Parameter(Mandatory)] + [hashtable]$FileSets, + + [Parameter(Mandatory)] + [hashtable]$CapabilityCatalog, + + [Parameter(Mandatory)] + [object]$Context, + + [string]$Enterprise, + [string]$Organization, + [object]$Repository, + [Parameter(Mandatory)] + [string]$TempPath, + [switch]$WhatIf + ) + + foreach ($policy in $Policies) { + $name = if ($policy.name) { $policy.name } else { [System.IO.Path]::GetFileName($policy.sourceFile) } + $capabilityId = "$($policy.layer).$($policy.capability)" + $capabilityDefinition = $CapabilityCatalog[$capabilityId] + $authMode = if ($capabilityDefinition.authMode) { [string]$capabilityDefinition.authMode } else { 'github-app' } + + if ($policy.enabled -ne $true) { + Write-Host "⏭️ Policy disabled: $name ($capabilityId)" + continue + } + + Write-Host "▶️ Applying policy: $name ($capabilityId)" + + switch ($capabilityId) { + 'enterprise.repo-custom-property' { + $typeName = if ($policy.config.typePropertyName) { [string]$policy.config.typePropertyName } else { 'Type' } + $subscriptionName = if ($policy.config.subscriptionPropertyName) { [string]$policy.config.subscriptionPropertyName } else { 'SubscribeTo' } + $required = if ($policy.config.ContainsKey('required')) { [bool]$policy.config.required } else { $false } + $valuesEditableBy = if ($policy.config.ContainsKey('valuesEditableBy')) { $policy.config.valuesEditableBy } else { 'org_actors' } + $requireExplicitValues = if ($policy.config.ContainsKey('requireExplicitValues')) { [bool]$policy.config.requireExplicitValues } else { $false } + + Sync-EnterpriseCustomPropertySchema ` + -Enterprise $policy.config.enterprise ` + -FileSets $FileSets ` + -Context $Context ` + -TypePropertyName $typeName ` + -SubscriptionPropertyName $subscriptionName ` + -Required $required ` + -ValuesEditableBy $valuesEditableBy ` + -RequireExplicitValues $requireExplicitValues ` + -AuthMode $authMode ` + -WhatIf:$WhatIf + } + 'enterprise.repo-rulesets' { + $rulesets = if ($policy.config.rulesets) { @($policy.config.rulesets) } else { @() } + Sync-EnterpriseRulesets -Enterprise $policy.config.enterprise -Rulesets $rulesets -Context $Context -AuthMode $authMode -WhatIf:$WhatIf + } + 'organization.none' { + Write-Host "ℹ️ Organization policy layer placeholder for $Organization" + } + 'repository.file-subscription-service' { + if (-not $Repository) { + throw "repository.file-subscription-service requires a repository scope." + } + + $owner = $Repository.Owner.Login + $repoName = $Repository.Name + $customProps = Get-RepositoryCustomProperties -Owner $owner -Repo $repoName -Context $Context + $typeProp = $customProps | Where-Object { $_.property_name -eq 'Type' } + $subscribeToProp = $customProps | Where-Object { $_.property_name -eq 'SubscribeTo' } + + $type = $typeProp.value + $subscribeTo = $subscribeToProp.value + + if (-not $type -or -not $subscribeTo) { + Write-Host "ℹ️ $owner/$repoName has no Type/SubscribeTo subscription, skipping." + $script:Summary.ReposSkipped++ + continue + } + if ($subscribeTo -is [string]) { $subscribeTo = @($subscribeTo) } + + $repoSpec = @{ + Name = $Repository.Name + Owner = $owner + FullName = $Repository.FullName + Type = $type + SubscribeTo = $subscribeTo + DefaultBranch = $Repository.DefaultBranch + } + + $branchName = if ($policy.config.branchName) { [string]$policy.config.branchName } else { 'managed-files/update' } + $commitMessage = if ($policy.config.commitMessage) { [string]$policy.config.commitMessage } else { 'chore: sync managed files' } + $prTitle = if ($policy.config.prTitle) { [string]$policy.config.prTitle } else { '⚙️ [Maintenance]: Sync managed files' } + $prLabel = if ($policy.config.prLabel) { [string]$policy.config.prLabel } else { 'NoRelease' } + $prBody = if ($policy.config.prBody) { + [string]$policy.config.prBody + } else { +@' +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 Custo repo instead. +'@ + } + + Sync-RepositoryFileSubscription ` + -Repository $repoSpec ` + -FileSets $FileSets ` + -TempPath $TempPath ` + -BranchName $branchName ` + -CommitMessage $commitMessage ` + -PRTitle $prTitle ` + -PRBody $prBody ` + -PRLabel $prLabel ` + -Context $Context ` + -WhatIf:$WhatIf + } + default { + throw "Unsupported policy capability '$capabilityId' in '$name'." + } + } + } +} + +try { + LogGroup '🔑 Authenticate bootstrap context' { + $rootContext = Connect-GitHubApp -PassThru + $rootContext | Format-List | Out-String + } + + LogGroup '🎯 Read target and policy scope' { + $targetsPath = Resolve-Path (Join-Path $PSScriptRoot '../config/targets.json') + $targetScope = Get-TargetScope -ConfigPath $targetsPath + Write-Host "Target scope: $($targetScope.scope)" + } + + LogGroup '📂 Discover managed file sets' { + $reposPath = Resolve-Path (Join-Path $PSScriptRoot '../Repos') + $fileSets = Get-FileSets -ReposPath $reposPath + if ($fileSets.Count -eq 0) { + throw 'No managed file sets discovered under Repos/.' + } + } + + LogGroup '🧩 Load capabilities and policy documents' { + $policyRoot = if ($targetScope.policy.rootPath) { $targetScope.policy.rootPath } else { '../PolicyEngine' } + if (-not [System.IO.Path]::IsPathRooted($policyRoot)) { + $policyRoot = Join-Path $PSScriptRoot $policyRoot + } + $policyRoot = Resolve-Path $policyRoot + + $policyEnterprise = [string]$targetScope.policy.enterprise + $capabilityCatalog = Get-CapabilityCatalog -CapabilityPath (Resolve-Path (Join-Path $policyRoot 'Capabilities')) + Write-Host "Loaded $($capabilityCatalog.Count) capability definitions" + } + + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "custo-sync-$(Get-Random)" + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + + try { + LogGroup '🏢 Enterprise layer' { + try { + $enterprisePolicyPaths = Resolve-PolicyLayerPaths -PolicyRoot $policyRoot -Enterprise $policyEnterprise -Layer 'enterprise' + $enterprisePolicies = Get-PolicyDocumentsFromPaths -Paths $enterprisePolicyPaths -CapabilityCatalog $capabilityCatalog -Layer 'enterprise' + Write-Host "Enterprise policies loaded: $($enterprisePolicies.Count)" + + Invoke-PoliciesForScope -Policies $enterprisePolicies -FileSets $fileSets -CapabilityCatalog $capabilityCatalog -Context $rootContext -Enterprise $policyEnterprise -TempPath $tempPath -WhatIf:$WhatIf + } catch { + Write-Host "❌ Enterprise layer failed: $_" + $script:Summary.Errors += "enterprise:$policyEnterprise : $_" + } + } + + LogGroup '🏢➡️🏬 Organization and repository layers' { + $organizations = Get-TargetOrganizations -TargetScope $targetScope -RootContext $rootContext + Write-Host "Organizations discovered: $($organizations -join ', ')" + + foreach ($org in $organizations) { + try { + $orgContext = $null + LogGroup "🔐 Connect org installation: $org" { + $orgContext = Get-OrganizationContext -Organization $org + } + + LogGroup "🏬 Organization policies: $org" { + $orgPolicyPaths = Resolve-PolicyLayerPaths -PolicyRoot $policyRoot -Enterprise $policyEnterprise -Layer 'organization' -Organization $org + $orgPolicies = Get-PolicyDocumentsFromPaths -Paths $orgPolicyPaths -CapabilityCatalog $capabilityCatalog -Layer 'organization' + Write-Host "Organization policies loaded: $($orgPolicies.Count)" + Invoke-PoliciesForScope -Policies $orgPolicies -FileSets $fileSets -CapabilityCatalog $capabilityCatalog -Context $orgContext -Enterprise $policyEnterprise -Organization $org -TempPath $tempPath -WhatIf:$WhatIf + } + + LogGroup "📦 Repositories in $org" { + $repos = Get-RepositoriesForOrganization -Organization $org -Context $orgContext + Write-Host "Repositories discovered in ${org}: $($repos.Count)" + } + + foreach ($repo in $repos) { + LogGroup "🏷️ Repository policies: $($repo.FullName)" { + $repoPolicyPaths = Resolve-PolicyLayerPaths -PolicyRoot $policyRoot -Enterprise $policyEnterprise -Layer 'repository' -Organization $org -Repository $repo.Name + $repoPolicies = Get-PolicyDocumentsFromPaths -Paths $repoPolicyPaths -CapabilityCatalog $capabilityCatalog -Layer 'repository' + Write-Host "Repository policies loaded: $($repoPolicies.Count)" + Invoke-PoliciesForScope -Policies $repoPolicies -FileSets $fileSets -CapabilityCatalog $capabilityCatalog -Context $orgContext -Enterprise $policyEnterprise -Organization $org -Repository $repo -TempPath $tempPath -WhatIf:$WhatIf + } + } + + $script:Summary.OrgsProcessed++ + } catch { + Write-Host "❌ Organization '$org' failed: $_" + $script:Summary.Errors += "org:$org : $_" + $script:Summary.OrgsFailed++ + } + } + } + } finally { + if (Test-Path $tempPath) { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Write-RunReport -Summary $script:Summary -WhatIf:$WhatIf + + Write-Host '' + Write-Host '📊 Summary' + Write-Host " Orgs OK: $($script:Summary.OrgsProcessed)" + Write-Host " Orgs fail: $($script:Summary.OrgsFailed)" + 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 +} diff --git a/tests/PolicyDocuments.Tests.ps1 b/tests/PolicyDocuments.Tests.ps1 new file mode 100644 index 0000000..f88efc8 --- /dev/null +++ b/tests/PolicyDocuments.Tests.ps1 @@ -0,0 +1,67 @@ +#requires -Modules Pester + +BeforeAll { + $engineRoot = Resolve-Path (Join-Path $PSScriptRoot '..' 'PolicyEngine') + $script:CapabilityFiles = @(Get-ChildItem -Path (Join-Path $engineRoot 'Capabilities') -Recurse -Filter '*.capability.json') + $script:PolicyFiles = @(Get-ChildItem -Path (Join-Path $engineRoot 'Policies') -Recurse -Filter '*.policy.json') + + # Build a catalog keyed by "layer.capability" for cross-checking policies. + $script:Catalog = @{} + foreach ($file in $script:CapabilityFiles) { + $doc = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $script:Catalog["$($doc.layer).$($doc.capability)"] = $doc + } +} + +Describe 'Capability documents' { + It 'ships at least one capability' { + $script:CapabilityFiles.Count | Should -BeGreaterThan 0 + } + + It ' is valid JSON with required fields and enums' -ForEach ( + Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'PolicyEngine' 'Capabilities') -Recurse -Filter '*.capability.json' | + ForEach-Object { @{ Name = $_.Name; Path = $_.FullName } } + ) { + $doc = Get-Content -Path $Path -Raw | ConvertFrom-Json + $doc.version | Should -Not -BeNullOrEmpty + $doc.name | Should -Not -BeNullOrEmpty + $doc.layer | Should -BeIn @('enterprise', 'organization', 'repository') + $doc.capability | Should -Match '^[a-z0-9]+(-[a-z0-9]+)*$' + $doc.authMode | Should -BeIn @('github-app', 'enterprise-pat') + } +} + +Describe 'Policy documents' { + It 'ships at least one policy' { + $script:PolicyFiles.Count | Should -BeGreaterThan 0 + } + + It ' is valid JSON with required fields' -ForEach ( + Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'PolicyEngine' 'Policies') -Recurse -Filter '*.policy.json' | + ForEach-Object { @{ Name = $_.Name; Path = $_.FullName } } + ) { + $doc = Get-Content -Path $Path -Raw | ConvertFrom-Json + $doc.version | Should -Not -BeNullOrEmpty + $doc.name | Should -Not -BeNullOrEmpty + $doc.layer | Should -BeIn @('enterprise', 'organization', 'repository') + $doc.capability | Should -Match '^[a-z0-9]+(-[a-z0-9]+)*$' + ($doc.PSObject.Properties.Name -contains 'enabled') | Should -BeTrue + } + + It ' references a known capability for its layer' -ForEach ( + Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'PolicyEngine' 'Policies') -Recurse -Filter '*.policy.json' | + ForEach-Object { @{ Name = $_.Name; Path = $_.FullName } } + ) { + $doc = Get-Content -Path $Path -Raw | ConvertFrom-Json + $key = "$($doc.layer).$($doc.capability)" + $script:Catalog.ContainsKey($key) | Should -BeTrue -Because "capability '$key' must exist in PolicyEngine/Capabilities" + } + + It ' lives under a folder matching its declared layer' -ForEach ( + Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'PolicyEngine' 'Policies') -Recurse -Filter '*.policy.json' | + ForEach-Object { @{ Name = $_.Name; Path = $_.FullName; Layer = ($_ | Get-Content -Raw | ConvertFrom-Json).layer } } + ) { + $separator = [System.IO.Path]::DirectorySeparatorChar + $Path | Should -Match ([regex]::Escape("$separator$Layer$separator")) + } +} diff --git a/tests/Sync-Files.Tests.ps1 b/tests/Sync-Files.Tests.ps1 new file mode 100644 index 0000000..76024a8 --- /dev/null +++ b/tests/Sync-Files.Tests.ps1 @@ -0,0 +1,140 @@ +#requires -Modules Pester + +BeforeAll { + # Load only the function definitions from the runtime script, without executing the + # top-level pipeline (which requires GitHub authentication). + $scriptPath = Resolve-Path (Join-Path $PSScriptRoot '..' 'scripts' 'Sync-Files.ps1') + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { + throw "Sync-Files.ps1 has parse errors: $($parseErrors -join '; ')" + } + $funcs = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) + $definitions = ($funcs | ForEach-Object { $_.Extent.Text }) -join "`n`n" + . ([scriptblock]::Create($definitions)) + + # Minimal stub so functions that log through LogGroup still run in tests. + function LogGroup { + param($Title, [scriptblock]$ScriptBlock) + & $ScriptBlock + } +} + +Describe 'ConvertTo-CanonicalObject' { + It 'sorts dictionary keys deterministically' { + $result = ConvertTo-CanonicalObject -InputObject @{ b = 1; a = 2; c = 3 } + @($result.Keys) | Should -Be @('a', 'b', 'c') + } + + It 'preserves array order' { + $result = ConvertTo-CanonicalObject -InputObject @('z', 'a', 'm') + $result | Should -Be @('z', 'a', 'm') + } + + It 'normalizes PSCustomObject the same as an equivalent hashtable' { + $fromJson = '{"b":1,"a":2}' | ConvertFrom-Json + $fromHash = @{ a = 2; b = 1 } + (ConvertTo-CanonicalObject $fromJson | ConvertTo-Json) | + Should -Be (ConvertTo-CanonicalObject $fromHash | ConvertTo-Json) + } +} + +Describe 'Test-CanonicalSubset' { + It 'treats server-added fields as satisfied' { + $current = ConvertTo-CanonicalObject ('{"a":1,"b":2,"extra":"x"}' | ConvertFrom-Json) + $desired = ConvertTo-CanonicalObject @{ a = 1; b = 2 } + Test-CanonicalSubset -Superset $current -Subset $desired | Should -BeTrue + } + + It 'detects real value drift' { + $current = ConvertTo-CanonicalObject ('{"a":1,"b":9}' | ConvertFrom-Json) + $desired = ConvertTo-CanonicalObject @{ a = 1; b = 2 } + Test-CanonicalSubset -Superset $current -Subset $desired | Should -BeFalse + } + + It 'treats array order as significant' { + $current = ConvertTo-CanonicalObject @{ values = @('Module', 'Action') } + $desired = ConvertTo-CanonicalObject @{ values = @('Action', 'Module') } + Test-CanonicalSubset -Superset $current -Subset $desired | Should -BeFalse + } + + It 'treats a null desired value as satisfied when the key is absent' { + $current = ConvertTo-CanonicalObject @{ a = 1 } + $desired = ConvertTo-CanonicalObject @{ a = 1; default_value = $null } + Test-CanonicalSubset -Superset $current -Subset $desired | Should -BeTrue + } +} + +Describe 'Write-ConfigDiff' { + It 'returns $false when the desired state is already satisfied' { + $current = '{"value_type":"single_select","required":false,"extra":"x"}' | ConvertFrom-Json + $desired = @{ value_type = 'single_select'; required = $false } + Write-ConfigDiff -Title 'test' -Current $current -Desired $desired | Should -BeFalse + } + + It 'returns $true when drift exists' { + $current = '{"value_type":"multi_select"}' | ConvertFrom-Json + $desired = @{ value_type = 'single_select' } + Write-ConfigDiff -Title 'test' -Current $current -Desired $desired | Should -BeTrue + } + + It 'returns $true when current state is missing' { + Write-ConfigDiff -Title 'test' -Current $null -Desired @{ a = 1 } | Should -BeTrue + } +} + +Describe 'Resolve-PolicyLayerPaths' { + BeforeAll { + $script:PolicyRootFixture = Join-Path ([System.IO.Path]::GetTempPath()) 'PolicyEngine' + } + + It 'returns the enterprise folder for the enterprise layer' { + $paths = Resolve-PolicyLayerPaths -PolicyRoot $script:PolicyRootFixture -Enterprise 'MSXOrg' -Layer 'enterprise' + $paths | Should -Contain (Join-Path $script:PolicyRootFixture 'Policies' 'MSXOrg' 'enterprise') + } + + It 'includes _default and org-specific folders for the repository layer' { + $paths = Resolve-PolicyLayerPaths -PolicyRoot $script:PolicyRootFixture -Enterprise 'MSXOrg' -Layer 'repository' -Organization 'PSModule' -Repository 'GitHub' + $repoBase = Join-Path (Join-Path (Join-Path $script:PolicyRootFixture 'Policies') 'MSXOrg') 'repository' + $paths | Should -Contain (Join-Path $repoBase '_default') + $paths | Should -Contain (Join-Path (Join-Path $repoBase 'PSModule') '_default') + $paths | Should -Contain (Join-Path (Join-Path $repoBase 'PSModule') 'GitHub') + } +} + +Describe 'Invoke-PoliciesForScope authMode routing' { + BeforeAll { + $script:TempFixture = [System.IO.Path]::GetTempPath() + } + + It 'routes the capability authMode to the enterprise ruleset handler' { + $script:CapturedAuthMode = $null + function Sync-EnterpriseRulesets { + param($Enterprise, $Rulesets, $Context, $AuthMode, [switch]$WhatIf) + $script:CapturedAuthMode = $AuthMode + } + + $catalog = @{ 'enterprise.repo-rulesets' = @{ authMode = 'enterprise-pat' } } + $policy = @{ + layer = 'enterprise' + capability = 'repo-rulesets' + enabled = $true + name = 'rs' + sourceFile = 'x' + config = @{ enterprise = 'MSXOrg'; rulesets = @(@{ name = 'r' }) } + } + + Invoke-PoliciesForScope -Policies @($policy) -FileSets @{} -CapabilityCatalog $catalog ` + -Context ([pscustomobject]@{}) -Enterprise 'MSXOrg' -TempPath $script:TempFixture -WhatIf + + $script:CapturedAuthMode | Should -Be 'enterprise-pat' + } + + It 'skips disabled policies' { + $catalog = @{ 'enterprise.repo-rulesets' = @{ authMode = 'enterprise-pat' } } + $policy = @{ layer = 'enterprise'; capability = 'repo-rulesets'; enabled = $false; name = 'rs'; sourceFile = 'x' } + { Invoke-PoliciesForScope -Policies @($policy) -FileSets @{} -CapabilityCatalog $catalog ` + -Context ([pscustomobject]@{}) -Enterprise 'MSXOrg' -TempPath $script:TempFixture -WhatIf } | Should -Not -Throw + } +}