Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ea663c2
adding hub-sync feature code
jefeish Aug 28, 2025
6457790
adjusting ui
jefeish Sep 1, 2025
498bbbd
hub improvements
jefeish Sep 10, 2025
dad3fe8
Add ui screen
jefeish Sep 24, 2025
5188801
updated README
jefeish Oct 2, 2025
de8bab4
handle multiple changes as a batch
decyjphr Oct 3, 2025
fa00d78
Update index.js
decyjphr Oct 3, 2025
b87397c
Update index.js
decyjphr Oct 3, 2025
6b358e5
depup files in a push
decyjphr Oct 5, 2025
c971041
Update index.js
decyjphr Oct 5, 2025
ac8e195
moved the dedup logic
decyjphr Oct 5, 2025
8bc76fc
Update index.js
decyjphr Oct 5, 2025
a5ef531
improved ui
jefeish May 8, 2026
b6887a2
Start at 2.1.18-rc1 and add roles plugin and enhance settings integr…
decyjphr May 15, 2026
bdcc6b5
Add custom repository roles schema to settings.json
decyjphr May 15, 2026
1d739f9
Add sub-org reevaluation logic and smoke tests
decyjphr May 19, 2026
baaa9d5
Add external group linking functionality for teams and update smoke t…
decyjphr May 19, 2026
6ca72a7
feat: add disable_plugins configuration to settings schema
decyjphr May 24, 2026
3cac68b
fix: add action.msg to dedup key so multiple disable_plugins NopComma…
decyjphr May 24, 2026
6938bf2
merge ydhav-issue-fix
jefeish May 26, 2026
a294cbd
feat: add support for additive_plugins in settings
decyjphr May 26, 2026
d03062a
fix: update .gitignore to ignore all .env files
decyjphr May 27, 2026
3312795
added base_url support
jefeish Jun 1, 2026
bfbd874
updated docs
jefeish Jun 1, 2026
4cb5e10
fixed sync log page
jefeish Jun 1, 2026
fa5020e
fix: update app.yml to remove empty line and add organization custom …
decyjphr Jun 2, 2026
d9be605
Refactor Variables Plugin: Simplify methods and add NopCommand support
decyjphr Jun 4, 2026
6a38988
fix: update variables handling in smoke test and add new repository Y…
decyjphr Jun 7, 2026
16b9375
Added PR989 changes
decyjphr Jun 7, 2026
1892ac7
Add reverse settings generator (issue #994)
decyjphr Jun 13, 2026
5fac715
feat: enhance smoke tests with custom repository roles and rulesets
decyjphr Jun 17, 2026
b250312
fix: enhance ruleset handling in MergeDeep and add tests for required…
decyjphr Jun 17, 2026
2bfc2e8
test: add ruleset comparison tests for required_reviewers and unnamed…
decyjphr Jun 17, 2026
e4498f1
added architecture diagram
jefeish Jun 18, 2026
79a8d43
hub-sync ui update
jefeish Jun 18, 2026
2d2f92f
feat: implement name-based resolution for ruleset bypass actors and r…
decyjphr Jun 23, 2026
2876b3f
Fix suborg-applied settings not removed when targeting rules change
decyjphr Jun 23, 2026
50fef03
Merge remote-tracking branch 'origin/decyjphr-fix-suborg-targeting-re…
jefeish Jun 23, 2026
4b9c44e
mergeConfig added
jefeish Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,27 @@

# Uncomment this to get GitHub comments for the Pull Request Workflow.
# ENABLE_PR_COMMENT=true

# ADMIN_REPO=safe-settings-config
CONFIG_PATH=.github
SETTINGS_FILE_PATH=settings.yml

# Configuration support for Hub-Sync safe-settings feature
# SAFE_SETTINGS_HUB_REPO=safe-settings-config-master
# SAFE_SETTINGS_HUB_ORG=foo-training
# A subfolder under 'CONFIG_PATH' where the 'organizations/<org>/<repo>' structure is found
# SAFE_SETTINGS_HUB_PATH=safe-settings
# SAFE_SETTINGS_HUB_DIRECT_PUSH=true



# ┌────────────── second (optional)
# │ ┌──────────── minute
# │ │ ┌────────── hour
# │ │ │ ┌──────── day of month
# │ │ │ │ ┌────── month
# │ │ │ │ │ ┌──── day of week
# │ │ │ │ │ │
# │ │ │ │ │ │
# * * * * * *
# CRON=* * * * * # Run every minute
34 changes: 34 additions & 0 deletions docs/hubSyncHandler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Safe Settings Organization Sync & Dashboard

This feature provides a centralized approach to managing the Safe-Settings Admin Repo, allowing Safe-Settings configurations to be sync'd across multiple ORGs.

## Overview

This feature adds a hub‑and‑spoke synchronization capability to Safe Settings.

One central **master admin repository** (the hub) serves as the authoritative source of configuration which is automatically propagated to each organization’s **admin repository** (the spokes).

**Note:** When something changes in the central repo, only those changed files are copied to each affected ORG’s admin repo, so everything stays in sync with little manual work.

## Sync Lifecycle (High Level)

```mermaid
graph TD
A0(PR Closed) --> A1(HUB Admin Repo)
A1(ORG Admin Repo) --> B(ORG Admin Repo)
A1(HUB Admin Repo) --> C(ORG Admin Repo)
A1(HUB Admin Repo) --> D(ORG Admin Repo)
```

## Environment Variables & Inputs

Environment variables specific to the 'Sync-Feature'

| Name | Purpose | Default |
|------|---------|---------|
| `SAFE_SETTINGS_HUB_REPO` | Repo for master safe-settings contents | admin-master |
| `SAFE_SETTINGS_HUB_ORG` | Organization that hold the Repo | admin-master-org |
| `SAFE_SETTINGS_HUB_PATH` | source folder | .github/safe-settings |
| `SAFE_SETTINGS_HUB_DIRECT_PUSH` | Use a PR or direct commit | false |


115 changes: 30 additions & 85 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,91 +6,22 @@ const Glob = require('./lib/glob')
const ConfigManager = require('./lib/configManager')
const NopCommand = require('./lib/nopcommand')
const env = require('./lib/env')
const { setupRoutes } = require('./lib/routes')
const { initCache } = require('./lib/installationCache')
const { hubSyncHandler } = require('./lib/hubSyncHandler')

let deploymentConfig

module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) => {
let appSlug = 'safe-settings'
async function syncAllSettings (nop, context, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
if (ref) {
return Settings.syncAll(nop, context, repo, config, ref)
} else {
return Settings.syncAll(nop, context, repo, config)
}
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}

async function syncSubOrgSettings (nop, context, suborg, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
return Settings.syncSubOrgs(nop, context, suborg, repo, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}
// Initialize all routes (static UI + API) via centralized module
setupRoutes(robot, getRouter)

async function syncSettings (nop, context, repo = context.repo(), ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
const configManager = new ConfigManager(context, ref)
const runtimeConfig = await configManager.loadGlobalSettingsYaml()
const config = Object.assign({}, deploymentConfig, runtimeConfig)
robot.log.debug(`config for ref ${ref} is ${JSON.stringify(config)}`)
return Settings.sync(nop, context, repo, config, ref)
} catch (e) {
if (nop) {
let filename = env.SETTINGS_FILE_PATH
if (!deploymentConfig) {
filename = env.DEPLOYMENT_CONFIG_FILE_PATH
deploymentConfig = {}
}
const nopcommand = new NopCommand(filename, repo, null, e, 'ERROR')
robot.log.error(`NOPCOMMAND ${JSON.stringify(nopcommand)}`)
Settings.handleError(nop, context, repo, deploymentConfig, ref, nopcommand)
} else {
throw e
}
}
}
// Initialize installation cache (env-controlled prefetch)
initCache(robot)

async function renameSync (nop, context, repo = context.repo(), rename, ref) {
async function renameSync(nop, context, repo = context.repo(), rename, ref) {
try {
deploymentConfig = await loadYamlFileSystem()
robot.log.debug(`deploymentConfig is ${JSON.stringify(deploymentConfig)}`)
Expand All @@ -115,13 +46,14 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}
}

/**
* Loads the deployment config file from file system
* Do this once when the app starts and then return the cached value
*
* @return The parsed YAML file
*/
async function loadYamlFileSystem () {
async function loadYamlFileSystem() {
if (deploymentConfig === undefined) {
const deploymentConfigPath = env.DEPLOYMENT_CONFIG_FILE_PATH
if (fs.existsSync(deploymentConfigPath)) {
Expand All @@ -133,7 +65,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return deploymentConfig
}

function getAllChangedSubOrgConfigs (payload) {
function getAllChangedSubOrgConfigs(payload) {
const pattern = Settings.SUB_ORG_PATTERN

const getMatchingFiles = (commits, type) =>
Expand All @@ -150,7 +82,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getAllChangedRepoConfigs (payload, owner) {
function getAllChangedRepoConfigs(payload, owner) {
const pattern = Settings.REPO_PATTERN

const getMatchingFiles = (commits, type) =>
Expand All @@ -167,7 +99,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getChangedRepoConfigName (files, owner) {
function getChangedRepoConfigName(files, owner) {
const pattern = Settings.REPO_PATTERN

const modifiedFiles = files.filter((s) => pattern.test(s))
Expand All @@ -178,7 +110,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}))
}

function getChangedSubOrgConfigName (files) {
function getChangedSubOrgConfigName(files) {
const pattern = Settings.SUB_ORG_PATTERN

const modifiedFiles = files.filter((s) => pattern.test(s))
Expand All @@ -188,7 +120,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
path: modifiedFile
}))
}
async function createCheckRun (context, pull_request, head_sha, head_branch) {
async function createCheckRun(context, pull_request, head_sha, head_branch) {
const { payload } = context
// robot.log.debug(`Check suite was requested! for ${context.repo()} ${pull_request.number} ${head_sha} ${head_branch}`)
const res = await context.octokit.checks.create({
Expand All @@ -200,7 +132,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
robot.log.debug(JSON.stringify(res, null))
}

async function info () {
async function info() {
const github = await robot.auth()
const installations = await github.paginate(
github.apps.listInstallations.endpoint.merge({ per_page: 100 })
Expand All @@ -215,7 +147,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
}
}

async function syncInstallation (nop = false) {
async function syncInstallation(nop = false) {
robot.log.trace('Fetching installations')
const github = await robot.auth()

Expand Down Expand Up @@ -521,6 +453,19 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return createCheckRun(context, pull_request, payload.pull_request.head.sha, payload.pull_request.head.ref)
})

/**
* @description Handle pull_request.closed events to support hub synchronization
* @param {Object} context - The context object provided by Probot
*/
robot.on('pull_request.closed', async context => {
try {
await hubSyncHandler(robot, context)
} catch (err) {
robot.log.error(`pull_request.closed handler failed: ${err && err.message ? err.message : err}`)
}
return null
})

robot.on(['check_suite.rerequested'], async context => {
robot.log.debug('Check suite was rerequested!')
return createCheckRun(context)
Expand Down
6 changes: 6 additions & 0 deletions lib/env.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
module.exports = {
ADMIN_REPO: process.env.ADMIN_REPO || 'admin',
SAFE_SETTINGS_HUB_REPO: process.env.SAFE_SETTINGS_HUB_REPO || 'admin-master',
SAFE_SETTINGS_HUB_ORG: process.env.SAFE_SETTINGS_HUB_ORG || 'admin-master-org',
SAFE_SETTINGS_HUB_DIRECT_PUSH: process.env.SAFE_SETTINGS_HUB_DIRECT_PUSH || 'false',
SAFE_SETTINGS_HUB_PATH: process.env.SAFE_SETTINGS_HUB_PATH || '.github/safe-settings',
APP_ID: process.env.APP_ID || null,
PRIVATE_KEY_PATH: process.env.PRIVATE_KEY_PATH || 'private-key.pem',
CONFIG_PATH: process.env.CONFIG_PATH || '.github',
SETTINGS_FILE_PATH: process.env.SETTINGS_FILE_PATH || 'settings.yml',
DEPLOYMENT_CONFIG_FILE_PATH: process.env.DEPLOYMENT_CONFIG_FILE || 'deployment-settings.yml',
Expand Down
Loading