-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Context
The PSModule organization maintains many repositories of the same type (modules, actions, reusable workflow). These repos share a large number of identical or near-identical files — linter configs, PSModule settings, copilot instructions, agent definitions, prompt files, GitHub Actions workflows, and more. Keeping these files in sync requires manual effort or ad-hoc scripting, which is error-prone and slow.
Solution overview
As an MVP, we want to create a central, convention-based mechanism for pushing shared files to repositories across the PSModule organization, allowing repos to subscribe to file sets. A scheduled GitHub Actions workflow, using the PSModule/GitHub-Script action that authenticates using the PSModule's Custo GitHub App, reads subscription preferences from repository custom properties (Type and SubscribeTo), and copies the requested file sets to the repo from a two-level folder structure in this repo. The workflow clones each target repo, copies managed files, and creates a pull request if files have changed. The PR is created as ready for review with a static title, description, and label that follow the organization's maintenance conventions. A human reviewer then approves and merges the PR.
Technical decisions
| Decision | Choice | Rationale |
|---|---|---|
| Runtime | PowerShell 7+ with the GitHub PSModule |
Consistent with the rest of the org's automation tooling |
| Authentication | PSModule's Custo GitHub App for file sync | Higher rate limits, fine-grained permissions, no PAT rotation needed |
| Execution | Scheduled GitHub Actions using PSModule/GitHub-Script action |
Free for public repos; handles GitHub module installation and authentication out of the box via ClientID/PrivateKey inputs |
| Subscription model | Two repo custom properties defined on the PSModule org: Type (single-select) + SubscribeTo (multi-select) |
Type groups file sets by repo kind; SubscribeTo lets repos self-select which sets within their type to receive |
| Configuration | Convention-based two-level folder structure — no settings file | The folder structure is the configuration. |
| Change detection | Git-native — clone, copy files, check git status |
git handles change detection natively |
| File push mechanism | Git clone → branch → copy files → commit → push → create PR | Changes go through a PR for visibility and review |
| PR metadata | Static title, description, and label applied at PR creation | Follows the org's maintenance conventions: ⚙️ [Maintenance]: Sync managed files, NoRelease label, and a description explaining the centralized sync |
| PR lifecycle | PR created as ready for review with static metadata | Human reviewer approves and merges |
| File deletion | No automatic deletion | Removing a file from a file set leaves the previously synced copy in target repos as unmanaged. Cleanup function may be added later |
Design overview
Convention-based repository structure
The Repos/ directory uses a two-level hierarchy: Repos///. The first level groups by repo type; the second level holds the subscribable file sets. Each file set folder mimics the root of the target repository.
Repos/
├── Module/
│ ├── Custom Instructions/
│ │ └── .github/
│ │ └── instructions/
│ │ ├── md.instructions.md
│ │ └── pwsh.instructions.md
│ ├── Prompts/
│ │ └── .github/
│ │ └── prompts/
│ │ └── ...
│ ├── Hooks/
│ │ └── .github/
│ │ └── hooks/
│ │ └── ...
│ ├── CODEOWNERS/
│ │ └── .github/
│ │ └── CODEOWNERS
│ ├── dependabot.yml/
│ │ └── .github/
│ │ └── dependabot.yml
│ ├── PSModule Settings/
│ │ └── .github/
│ │ ├── PSModule.yml
│ │ ├── mkdocs.yml
│ │ └── release.yml
│ ├── Linter Settings/
│ │ └── .github/
│ │ └── linters/
│ │ ├── .markdown-lint.yml
│ │ ├── .powershell-psscriptanalyzer.psd1
│ │ └── .textlintrc
│ ├── .gitattributes/
│ │ └── .gitattributes
│ ├── .gitignore/
│ │ └── .gitignore
│ └── License/
│ └── LICENSE
├── Action/
│ ├── Custom Instructions/
│ │ └── .github/
│ │ └── instructions/
│ │ └── ...
│ ├── Linter Settings/
│ │ └── .github/
│ │ └── linters/
│ │ └── ...
│ ├── .gitattributes/
│ │ └── .gitattributes
│ ├── .gitignore/
│ │ └── .gitignore
│ └── License/
│ └── LICENSE
├── Template/
│ └── ...
└── Workflow/
└── ...
Key rules:
- No config file. The folder tree defines everything.
- First-level folder names under
Repos/= allowed values for theTypecustom property. - Second-level folder names under each type = allowed values for the
SubscribeTocustom property. - Each file set folder's contents are rooted at the target repo root (i.e.,
.github/linters/foo.ymlin the file set maps to.github/linters/foo.ymlin the target repo). - A new file set is created by adding a new custom property value to the
SubscribeTocustom property definition on the PSModule organization and creating a new folder under the appropriate type with the files to sync, where the name of the folder corresponds with the name of the value that was added to theSubscribeTocustom property. - Different types can have different selections available — a Module may have
PSModule Settingswhile an Action does not.
Subscription model
The subscription model uses two custom properties defined at the organization level with values set on each repo, with 'Allow repository actors to set this property' enabled.
| Property | Type | Allowed values | Purpose |
|---|---|---|---|
Type |
Single-select | Module, Action, Template, Workflow, Docs, Other |
Determines which type-level folder to look in |
SubscribeTo |
Multi-select | Custom Instructions, Prompts, Hooks, CODEOWNERS, dependabot.yml, PSModule Settings, Linter Settings, .gitattributes, .gitignore, License |
Determines which file set folders within the type to sync |
Note
SubscribeTo allowed values are the union of all selection folder names across all types. If a repo subscribes to a selection that doesn't exist under its type folder, the workflow logs a warning and skips it.
How it works:
- The organization defines both
TypeandSubscribeTocustom properties with predefined value options. - 'Allow repository actors to set this property' is enabled on both, so repo admins/maintainers can manage their own subscriptions.
- Each repository sets its
Typeand selects itsSubscribeTovalues. - The workflow reads both properties and resolves:
Repos/{Type}/{Selection}/for each subscribed selection.
Example: A repo with Type = Module and SubscribeTo = Linter Settings, PSModule Settings, Custom Instructions receives all files from:
Repos/Module/Linter Settings/Repos/Module/PSModule Settings/Repos/Module/Custom Instructions/
The workflow is intentionally simple
The workflow logic is deliberately simple and stateless:
- Read each repo's
TypeandSubscribeToproperties. Don't validate the values against an allowed value, as the platform already determines this. - For each
SubscribeTovalue, look for a matching folder atRepos/{Type}/{Value}/. - If the folder exists, collect its files. If not, warn and skip.
- Clone the target repo, create a branch, copy source files over, and check if git detects any changes.
- If any files differ, commit, push the branch, and create a PR. If nothing changed, skip entirely.
There is no state tracking, no manifest, no diff history. The folder structure is the only source of truth. The workflow simply ensures the target repos match the source folders — nothing more.
Change detection
The workflow uses git for change detection:
- Clone the target repo — Shallow clone (
--depth 1) of the default branch into a temporary directory. The GitHub App's installation access token is used for HTTPS authentication. - Create a branch — Create and check out a new branch named
managed-files/update. - Copy source files — Copy all files from the resolved file set folders into the clone, overwriting existing files and creating new ones as needed.
- Check for changes — Run
git status --porcelain. If the output is empty, nothing changed. - Skip if nothing changed — If git reports no modifications, skip the repo entirely. No branch, no PR, no commit is created.
File deletion behavior
This repo only creates and updates files — it never deletes files from subscribing repos. If a file is removed from a file set, the previously synced copy remains in the target repo but is no longer managed or updated. A dedicated cleanup function may be added in a future iteration.
PR lifecycle
The workflow creates a ready-for-review PR with static metadata that follows the organization's maintenance conventions:
Using the PSModule's Custo GitHub App:
- Clone repo, create branch, copy files, commit, push
- Create PR (ready for review) with static title, description, and label
The PR is created with:
- Title:
⚙️ [Maintenance]: Sync managed files - Label:
NoRelease - Description: A static body explaining that the files are centrally managed and were synced from this repo.
Once the PR is created, a human reviewer inspects the changes, approves, and merges.
Static PR metadata
All managed-file PRs use the same static title, description, and label. This follows the organization's maintenance conventions.
Title:
⚙️ [Maintenance]: Sync managed files
Label: NoRelease
Description:
This pull request was automatically created by the [Distributor] https://github.com/PSModule/Distributor) 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 Distributor repo instead.Existing PR handling: If a managed-files/update branch already exists (from a previous run that hasn't merged yet), the workflow force-pushes to the existing branch, which updates the existing PR in place rather than creating duplicates.
Sync algorithm
1. GitHub-Script action installs the GitHub module and authenticates as PSModule's Custo (GitHub App) → creates APP context
2. Script calls Connect-GitHubApp to create Installation Access Token (IAT) contexts for the org → enables repo-level API calls and git operations
3. Discover available file sets by scanning Repos/// folder structure
4. Query all org repos with their Type and SubscribeTo custom property values → Get-GitHubRepository -Owner {org} (CustomProperties included on each returned object)
5. For each repo where both Type and SubscribeTo are set:
a. Resolve the type folder: Repos/{Type}/
b. For each value in SubscribeTo:
- Source = Repos/{Type}/{Selection}/
- If source folder does not exist, log warning and skip
- Enumerate all files recursively, compute relative paths from the file set root
c. Collect all files across all selected file sets
d. Clone the target repo (shallow, default branch) using HTTPS + installation token
(git auth is handled by `Set-GitHubGitConfig` url.insteadOf rules configured during step 2)
e. Create and checkout branch: managed-files/update
f. Copy all collected source files into the clone (overwrite existing, create new)
g. Run `git status --porcelain` to detect changes
h. If no changes → skip repo (log 'already in sync'), remove clone
i. If changes detected:
- `git add --all`
- `git commit -m 'chore: sync managed files'`
- `git push --force --set-upstream origin managed-files/update`
- Create PR via API with static title (`⚙️ [Maintenance]: Sync managed files`), description, and `NoRelease` label
(or update the existing PR if a `managed-files/update` branch already has one)
j. Log: repo name, files changed (from git status output), PR URL
k. Remove the temporary clone directory
6. Output summary: total repos processed, PRs created/updated, already in sync, errors
GitHub-Script action usage
The workflow uses PSModule/GitHub-Script which provides:
- Automatic installation and import of the
GitHubPowerShell module - Built-in GitHub App authentication via
ClientIDandPrivateKeyinputs — this creates an APP-level context (AuthType = 'APP') with a JWT that can only call/app/*endpoints - Error handling and output management
Important
The APP context created by GitHub-Script is not sufficient for repo-level operations (API calls, git clone/push). The script must explicitly call Connect-GitHubApp as its first step to create Installation Access Token (IAT) contexts (AuthType = 'IAT', ghs_* tokens) that have repo-level permissions. Set-GitHubGitConfig — which configures git url.insteadOf rules for transparent HTTPS authentication — is called automatically by Set-GitHubContext when IAT contexts are created on GitHub Actions.
- name: Sync managed files
uses: PSModule/GitHub-Script@main
with:
Script: ./scripts/Sync-Files.ps1
ClientID: ${{ secrets.CUSTO_BOT_CLIENT_ID }}
PrivateKey: ${{ secrets.CUSTO_BOT_PRIVATE_KEY }}File push mechanism
Changed files for a single repo are delivered via a pull request:
- Clone —
git clone --depth 1 https://github.com/{owner}/{repo}.git {tempDir}— shallow clone of the default branch. Authentication is handled automatically bySet-GitHubGitConfig, which configuresurl.<token>@host.insteadOfrules when the GitHub App installation context is created. - Branch —
git checkout -b managed-files/update— create a dedicated branch for managed file changes. - Copy — Copy all managed files from the source folders into the clone directory, preserving relative paths. Existing files are overwritten; new files are created.
- Detect —
git status --porcelainin the clone directory. If output is empty, nothing changed → skip. - Stage —
git add --allto stage all changes. - Commit —
git commit -m 'chore: sync managed files'— single commit with all changes. - Push —
git push --force --set-upstream origin managed-files/updateto push the branch (force-push to handle re-runs). - Create PR — Create a pull request targeting the default branch with the static title (
⚙️ [Maintenance]: Sync managed files), description, andNoReleaselabel, or update the existing one if a PR frommanaged-files/updatealready exists. - Cleanup — Remove the temporary clone directory.
GitHub App permissions required
PSModule's Custo:
| Permission | Access | Purpose |
|---|---|---|
contents |
Write | Clone repos, push branches |
pull_requests |
Write | Create PRs, apply labels |
repository_custom_properties |
Read | Read SubscribeTo and Type custom property values per repo |
metadata |
Read | Search repositories, list collaborators, access repo metadata |
Workflow triggers
The workflow runs on a schedule and can be triggered manually. It does not run on push — since it compares content and only updates when files differ, a scheduled run is sufficient.
on:
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
workflow_dispatch:GitHub module API coverage
| Operation | Module function | Status |
|---|---|---|
| Auth as GitHub App (APP context) | Connect-GitHub -ClientID -PrivateKey (via GitHub-Script action) |
✅ Available |
| Create IAT contexts for repo access | Connect-GitHubApp (must be called explicitly by the script) |
✅ Available |
| Configure git identity + HTTPS auth | Set-GitHubGitConfig (called automatically when IAT context is created on GH Actions) |
✅ Available |
| List org repos + custom properties | Get-GitHubRepository (requires IAT context, not APP) |
✅ Available (returns CustomProperties on each repo object) |
| Create pull request | — | Invoke-GitHubAPI → POST /repos/{owner}/{repo}/pulls |
Note
Authentication flow: Connect-GitHub -ClientID -PrivateKey creates an APP context with a JWT (10-min lifetime, auto-refreshed). This JWT can only call /app/* endpoints. To perform repo-level operations, the script must call Connect-GitHubApp which exchanges the JWT for Installation Access Tokens (ghs_*, 1-hour lifetime) via POST /app/installations/{id}/access_tokens. IAT contexts are stored and become the default context for subsequent API and git operations. Set-GitHubGitConfig is automatically invoked for each IAT context on GitHub Actions, configuring url.https://oauth2:{token}@github.com/{installationName}.insteadOf rules for transparent git auth.
Acceptance criteria
- File sets are defined by a two-level folder structure under
Repos///(no config file) - Each file set folder mimics the target repo root
- Organization defines two repo custom properties:
Type(single-select) andSubscribeTo(multi-select) with predefined values matching the folder names - Individual repos self-select their type and subscriptions by setting their own property values
- A scheduled GitHub Actions workflow using
PSModule/GitHub-Scriptauthenticates as the PSModule's Custo GitHub App and syncs files to all subscribing repos - The workflow clones each target repo, creates a branch, copies managed files, and only commits/pushes when git detects actual changes — repos already in sync are skipped
- Changes are delivered via pull requests, not direct pushes to the default branch
- PRs are created with static metadata: title (
⚙️ [Maintenance]: Sync managed files), a description explaining the centralized sync, and theNoReleaselabel - Only files that actually differ are included in the commit
- Files are pushed as a single atomic commit per repo
- Files are forcefully overwritten in target repos (create or update, not merge)
- Files removed from a file set are not deleted from target repos (they become unmanaged)
- If a
managed-files/updatebranch already exists, the workflow force-pushes to update the existing PR rather than creating duplicates - The workflow runs on a daily schedule and on
workflow_dispatch - The workflow logs which repos had PRs created/updated, which were already in sync, which files were changed, and any errors
- The README documents the folder structure convention, subscription model, PR lifecycle, and how to add new file sets
Implementation plan
Repository structure
- Create the
Repos/directory with type sub-folders (Module,Action,Template,Workflow, etc.) - Create initial file set folders under each type with the canonical source files
- Verify folder naming matches the custom property values exactly
Custom property setup
- Define the
Typerepo custom property at the organization level (single-select) with values:Module,Action,Template,Workflow,Docs,Other - Define the
SubscribeTorepo custom property at the organization level (multi-select) with values:Custom Instructions,Prompts,Hooks,CODEOWNERS,dependabot.yml,PSModule Settings,Linter Settings,.gitattributes,.gitignore,License - Enable 'Allow repository actors to set this property' on both properties
- Set properties on a few test repos to validate the subscription model
GitHub App setup
- Configure PSModule's Custo with permissions:
-
contents: write -
pull_requests: write -
repository_custom_properties: read -
metadata: read
-
- Install the app on all target repositories (or org-wide)
- Store the app's credentials as repository secrets in this repo:
CUSTO_BOT_CLIENT_IDandCUSTO_BOT_PRIVATE_KEYfor PSModule's Custo
GitHub Actions workflow
- Create
.github/workflows/sync-files.ymlwithschedule(daily) andworkflow_dispatchtriggers - Use
PSModule/GitHub-Script@mainwithCUSTO_BOT_CLIENT_IDandCUSTO_BOT_PRIVATE_KEYsecrets for PSModule's Custo authentication - Script scans
Repos//to discover available file sets per type - Script queries org repos and their
Type+SubscribeTocustom property values - For each subscribing repo, resolve file sets via
Repos/{Type}/{Selection}/ - Clone target repo (shallow), create
managed-files/updatebranch, copy managed files, checkgit statusfor changes - Commit and push branch only if changes are detected
- Create PR (or update existing) with static title, description, and
NoReleaselabel - Handle existing
managed-files/updatebranches by force-pushing to update the PR in place - Log summary of actions: PRs created/updated, repos in sync, files changed, errors
- Handle edge cases: missing type folder, missing file set folder, empty file set, git errors
PowerShell script
- Create
scripts/Sync-Files.ps1as the main script - First line of script must call
Connect-GitHubAppto create IAT contexts (APP context from GitHub-Script cannot do repo operations) - Dot-source helper scripts from
scripts/helpers/if needed - Implement discovery logic to scan
Repos///folder structure - Implement clone-branch-copy-detect-push-PR logic:
- Clone target repo shallow (
git clone --depth 1) using HTTPS - Create and checkout
managed-files/updatebranch - Copy managed files into clone directory
- Check
git status --porcelainfor changes - If changes:
git add --all,git commit,git push --force - Create PR via API or update existing PR with static title, description, and
NoReleaselabel - If no changes: skip and log
- Cleanup temporary clone directory
- Clone target repo shallow (
- Use
Invoke-GitHubAPIfor any API endpoints not covered by the module (PR creation and labeling)
Documentation
- Write README covering: purpose, two-level folder structure convention, custom property setup, adding new file sets, PR lifecycle, change detection behavior, file deletion behavior, workflow triggers, and GitHub App requirements
- Document required GitHub App permissions for PSModule's Custo
- Document required repository secrets:
CUSTO_BOT_CLIENT_ID,CUSTO_BOT_PRIVATE_KEY(for PSModule's Custo)
Testing and validation
- Test the workflow against a test repository to verify file creation and overwrite behavior
- Verify that repos already in sync receive zero PRs/commits
- Verify that only changed files are included in commits (unchanged files are not re-pushed)
- Verify that PRs are created successfully
- Verify that PRs have the correct static title, description, and
NoReleaselabel - Verify that re-running the workflow updates existing PRs (force-push to existing branch)
- Verify that repos without
TypeorSubscribeToproperties are not affected - Verify that subscribed selections missing under a type folder produce warnings, not failures
- Verify that removing a file from a file set does not delete it from target repos
- Verify logging output is clear and actionable
- Verify single-commit behavior (all changed files in one commit per repo)