Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# The organization where you want to register the app in the app creation manifest flow.
# If set, the app is registered for an organization (https://github.com/organizations/ORGANIZATION/settings/apps/new),
# if not set, the GitHub app would be registered for the user account (https://github.com/settings/apps/new).
# Also selects the account for CLI and cron full syncs (case-insensitive).
# If set without a matching installation, the sync fails instead of falling back.
# If unset, full sync processes all repository-owning installations sequentially.
# GH_ORG=

# The ID of your GitHub App
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,22 @@ You can pass environment variables; the easiest way to do it is via a `.env` fil
```
CREATE_DEFAULT_BRANCH=true
```
1. Scope CLI and cron full syncs to one account using `GH_ORG`. For e.g.
```
GH_ORG=my-org
```
A full sync (`CRON` or `npm run full-sync`) selects the installation whose
account login matches `GH_ORG` case-insensitively and reads configuration from
that account's `<ADMIN_REPO>`. If no installation matches, including when the
app has no installations, the sync fails instead of syncing another account.
This also applies to dry runs (`FULL_SYNC_NOP=true`); the CLI exits nonzero on
a targeting error, and cron reports the execution as failed.
When `GH_ORG` is unset, all repository-owning installations are synced
sequentially. Set it to restrict full sync to one account; enterprise
installations supply app-management context but are not repository sync targets.
Webhook events still use their own installation context. `GH_ORG` is also used
by the [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest)
to choose where the app is registered.


### Runtime Settings
Expand Down Expand Up @@ -1037,6 +1053,9 @@ node smoke-test.js --phase 1,3,5
# Bypass actor apply/NOP convergence (Phase 1 creates the required test repo)
node smoke-test.js --phase 1,19

# Explicit GH_ORG full-sync CLI dry run (setup + Phase 24 + teardown)
node smoke-test.js --phase 24

# Repository and environment variables, including pagination and no-op convergence
node smoke-test.js --phase 1,13,21

Expand Down Expand Up @@ -1072,6 +1091,7 @@ The smoke test runs the following phases:
| **Phase 19** | Tests ignored `OrganizationAdmin`/`DeployKey` IDs, order-independent NOP convergence, real bypass-mode/role-ID changes, and no redundant updates (requires Phase 1) |
| **Phase 21** | Reads all pages of 101 repository variables and 100 environment variables, verifies unchanged NOP/apply writes nothing, and updates only the two boundary variables (requires Phases 1 and 13) |
| **Phase 22** | Runs real full-sync NOP against the verified `GH_ORG` installation with controlled enumeration/auth and read-only, org-scoped requests. Requires only Setup; multi-installation fanout and failure isolation are covered by unit/CLI tests, not this single-org smoke |
| **Phase 24** | Runs the real full-sync CLI with explicit `GH_ORG` and `FULL_SYNC_NOP=true`, verifying the selected installation and successful completion; multi-account, no-match, and cron failure scenarios use local mocked tests |
| **Teardown** | Shuts down safe-settings, deletes test repos, teams, custom roles, and rulesets |

The harness rejects a nonempty `CRON` before authentication or setup. However,
Expand Down
27 changes: 18 additions & 9 deletions docs/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ Follow the [Create the GitHub App](deploy.md#create-the-github-app) guide to cre
## Defining the GitHub Action Workflow
Running a full-sync with `safe-settings` can be done via `npm run full-sync`. This requires installing Node, such as with [actions/setup-node](https://github.com/actions/setup-node) (see example below). When doing so, the appropriate environment variables must be set (see the [Environment variables](#environment-variables) document for more details).

Manual and scheduled (`CRON`) full syncs process every repository-owning App
installation sequentially, using each installation's token and account login for
its admin repository. Enterprise installations are not repository sync targets;
When `GH_ORG` is unset, manual and scheduled (`CRON`) full syncs process every
repository-owning App installation sequentially, using each installation's token
and account login for its admin repository. Enterprise installations are not repository sync targets;
they remain available for enterprise app-management context enrichment.
An authentication, configuration, or sync failure does not stop later installations.
The returned `{ results, errors }` retains each returned Settings result (including
partial results) and aggregates their errors with thrown and missing-result failures.
An info-level summary counts installations with errors as failed. `FULL_SYNC_NOP=true`
previews every installation and still reports failures with a nonzero CLI exit.
No eligible installations returns `null` from `syncInstallation` and exits the CLI
nonzero with an explicit diagnostic.
previews the selected installations and still reports failures with a nonzero CLI exit.
With `GH_ORG` unset, no eligible installations returns `null` from
`syncInstallation` and exits the CLI nonzero with an explicit diagnostic.

`GH_ORG` does not restrict this full-sync path. Do not use a multi-installation App
for a live single-organization test by setting `GH_ORG` alone; the smoke test's
Phase 22 uses a controlled installation list and auth boundary instead.
`GH_ORG` restricts full sync to the matching account and fails if no eligible
installation matches. Startup `info()` still authenticates independently to read
the app slug, and webhook events use their own installation contexts. A live
single-organization smoke test therefore also needs an authentication/API boundary
and verified runtime configuration, not just `GH_ORG`. Phase 22 uses a controlled
installation list and auth boundary.

Installation repositories are processed in batches of up to ten, with each batch
settling before the next starts. A repository failure does not stop later
Expand All @@ -38,6 +41,12 @@ for suborg configuration changes; existing repository restrictions still apply.
### Example GHA Workflow
The below example uses the GHA "cron" feature to run a full-sync every 4 hours. While not required, this example uses the `.github` repo as the `admin` repo (set via `ADMIN_REPO` env var) and the safe-settings configurations are stored in the `safe-settings/` directory (set via `CONFIG_PATH` and `DEPLOYMENT_CONFIG_FILE`).

`GH_ORG` selects the account to sync, matching its login case-insensitively.
Set it when the App is installed on multiple accounts; without it, full sync
processes all repository-owning installations. If no installation matches,
the sync exits nonzero rather than syncing another account, including in dry
runs (`FULL_SYNC_NOP=true`).

```yaml
name: Safe Settings Sync
on:
Expand Down
2 changes: 1 addition & 1 deletion full-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const { FULL_SYNC_NOP } = require('./lib/env')
const { createProbot } = require('probot')

async function performFullSync (appFn, nop) {
const probot = createProbot()
const probot = await createProbot().ready()
probot.log.info(`Starting full sync with NOP=${nop}`)

try {
Expand Down
18 changes: 15 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>

async function syncInstallation (nop = false) {
robot.log.trace('Fetching installations')
const installations = (await listAllInstallations()).filter(installation => {
let installations = (await listAllInstallations()).filter(installation => {
// Enterprise installations supply app-management credentials, not repos.
if (installation.target_type === 'Enterprise') {
robot.log.debug(`Skipping enterprise installation ${installation.id} for repository sync`)
Expand All @@ -425,6 +425,17 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
return true
})

if (env.GH_ORG) {
const installation = installations.find(i =>
typeof i.account?.login === 'string' && i.account.login.toLowerCase() === env.GH_ORG.toLowerCase()
)
if (!installation) {
const accounts = installations.map(i => i.account?.login).join(', ')
throw new Error(`No app installation found for GH_ORG '${env.GH_ORG}'. Installed on: [${accounts}]`)
}
installations = [installation]
}

if (installations.length === 0) {
return null
}
Expand All @@ -439,7 +450,7 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
if (typeof owner !== 'string' || !owner.trim()) {
throw new Error(`Installation ${installation.id} has no account login for repository sync`)
}
robot.log.debug(`Syncing installation ${installation.id} for ${owner}`)
robot.log.info(`Syncing installation ${installation.id} on account ${owner}`)
const github = await robot.auth(installation.id)
const context = {
payload: {
Expand Down Expand Up @@ -1055,9 +1066,10 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
cron.schedule(process.env.CRON, async () => {
robot.log.debug('running a task every minute')
try {
await syncInstallation()
return await syncInstallation()
} catch (e) {
robot.log.error(`Scheduled full sync failed: ${e}`)
throw e
}
})
}
Expand Down
1 change: 1 addition & 0 deletions lib/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
BLOCK_REPO_RENAME_BY_HUMAN: process.env.BLOCK_REPO_RENAME_BY_HUMAN || 'false',
CREATE_DEFAULT_BRANCH: process.env.CREATE_DEFAULT_BRANCH === 'true',
FULL_SYNC_NOP: process.env.FULL_SYNC_NOP === 'true',
GH_ORG: process.env.GH_ORG,
GHE_HOST: process.env.GHE_HOST,
GHE_PROTOCOL: process.env.GHE_PROTOCOL,
}
46 changes: 45 additions & 1 deletion smoke-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ class InteractiveExit extends Error {
// ─── Octokit client (initialized in main) ────────────────────────────────────

let octokit = null
let orgInstallation = null
// Enterprise-installation-authenticated client (Phase 17). Null when the app
// is not installed on the enterprise or GH_ENTERPRISE is unset.
let entOctokit = null
Expand Down Expand Up @@ -3539,6 +3540,47 @@ async function phase19BypassActorConvergence () {
}
}

async function phase24OrganizationSyncTargeting () {
logPhase('Phase 24: Organization-targeted full-sync dry run')
if (!assert(process.env.GH_ORG && orgInstallation?.account?.login?.toLowerCase() === ORG.toLowerCase(),
'24: explicit GH_ORG matches the authenticated test installation')) {
throw new Error('Phase 24 requires an explicitly configured and verified test organization')
}

const result = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [path.join(__dirname, 'full-sync.js')], {
cwd: __dirname,
env: {
...process.env,
GH_ORG: ORG,
FULL_SYNC_NOP: 'true',
CRON: '',
LOG_LEVEL: 'info'
},
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120000
})
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
child.on('error', error => {
logFail(`24: could not start full-sync CLI: ${error.message}`)
reject(error)
})
child.on('close', (code, signal) => resolve({ code, signal, output }))
})

const selected = [...result.output.matchAll(/Syncing installation (\d+) on account ([A-Za-z0-9-]+)/g)]
assert(result.code === 0, `24: full-sync CLI exited successfully (code=${result.code}, signal=${result.signal})`)
assert(result.output.includes('Starting full sync with NOP=true'), '24: full-sync CLI ran in NOP mode')
assert(selected.length === 1 && Number(selected[0][1]) === orgInstallation.id &&
selected[0][2].toLowerCase() === ORG.toLowerCase(),
'24: sync selected exactly the authenticated test organization installation')
assert(result.output.includes('Full sync completed successfully.'), '24: actual full-sync entrypoint completed')
assert(!/Unexpected error during full sync|Fatal error during full sync|Errors occurred during full sync/.test(result.output),
'24: full-sync CLI reported no failure')
}

async function phase22InstallationFullSync (app, installationId) {
logPhase('Phase 22: Test-org installation full-sync NOP')
if (!process.env.GH_ORG || process.env.GH_ORG.toLowerCase() !== ORG.toLowerCase() || process.env.CRON) {
Expand Down Expand Up @@ -3629,6 +3671,7 @@ async function main () {
// Org/user installations key off account.login (only enterprise accounts have a slug).
if (installation.account && installation.account.login && installation.account.login.toLowerCase() === ORG.toLowerCase()) {
installationId = installation.id
orgInstallation = installation
break
}
}
Expand Down Expand Up @@ -3691,7 +3734,8 @@ async function main () {
['Phase 19: Bypass actor convergence', phase19BypassActorConvergence],
['Phase 20: Custom property exclusions', phase20CustomPropertyExclusions],
['Phase 21: Variable pagination', phase21VariablePagination],
['Phase 22: Test-org installation full-sync NOP', () => phase22InstallationFullSync(app, installationId)]
['Phase 22: Test-org installation full-sync NOP', () => phase22InstallationFullSync(app, installationId)],
['Phase 24: Organization sync targeting', phase24OrganizationSyncTargeting]
]

// When --phase is given, only run setup (phase 0) + the requested phase(s).
Expand Down
103 changes: 103 additions & 0 deletions test/unit/full-sync.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,106 @@
/* eslint-disable no-undef */
const { spawnSync } = require('child_process')
const { generateKeyPairSync } = require('crypto')
const path = require('path')

describe('full-sync CLI with the installed Probot runtime', () => {
let privateKey

beforeAll(() => {
privateKey = generateKeyPairSync('rsa', {
modulusLength: 2048,
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
publicKeyEncoding: { type: 'pkcs1', format: 'pem' }
}).privateKey
})

it.each([
[false, 'success'],
[true, 'success'],
[false, 'missing-target'],
[true, 'missing-target'],
[false, 'sync-errors'],
[true, 'sync-errors']
])('initializes before logging and preserves NOP=%s %s reporting', (nop, scenario) => {
const cli = spawnSync(process.execPath, ['-e', `
const forbidNetwork = () => { throw new Error('Network access forbidden in CLI regression') }
global.fetch = forbidNetwork
require('http').request = forbidNetwork
require('https').request = forbidNetwork
require('net').Socket.prototype.connect = forbidNetwork

const { Probot } = require('probot')
Probot.prototype.auth = async function (id) {
await this.ready()
if (id !== undefined && ![1, 2].includes(id)) throw new Error('Unexpected installation')
return {
paginate: async () => [
{ id: 1, account: { login: 'other-org' } },
{ id: 2, account: { login: 'test-org' } }
],
rest: {
apps: {
listInstallations: { endpoint: { merge: options => options } },
getAuthenticated: async () => ({ data: { slug: 'test-app' } })
},
repos: { getContent: async () => ({ data: { content: '' } }) }
}
}
}
const Module = require('module')
const load = Module._load
Module._load = function (name, parent, main) {
if (name === './lib/settings' && parent.filename === require.resolve('./index')) {
return {
syncAll: async (nop, context, repo) => {
console.log('SYNC_RESULT ' + JSON.stringify({ nop, owner: repo.owner, installation: context.payload.installation.id }))
return { errors: process.env.TEST_SCENARIO === 'sync-errors' ? [{ msg: 'test sync failure' }] : [] }
}
}
}
return load.call(this, name, parent, main)
}
require('./full-sync')
`], {
cwd: path.resolve(__dirname, '../..'),
encoding: 'utf8',
timeout: 10000,
env: {
PATH: process.env.PATH,
APP_ID: '1',
PRIVATE_KEY: privateKey,
GH_ORG: scenario === 'missing-target' ? 'missing-org' : 'TeSt-OrG',
FULL_SYNC_NOP: String(nop),
TEST_SCENARIO: scenario,
LOG_FORMAT: 'json',
LOG_LEVEL: 'info',
DEPLOYMENT_CONFIG_FILE: path.join(__dirname, 'no-such-deployment-settings.yml')
}
})
const output = cli.stdout + cli.stderr

expect(cli.error).toBeUndefined()
expect(output).not.toContain('Network access forbidden')
expect(output).not.toContain('"owner":"other-org"')
expect(output).toContain(`Starting full sync with NOP=${nop}`)
expect(cli.status).toBe(scenario === 'success' ? 0 : 1)
if (scenario === 'missing-target') {
expect(output).toContain("No app installation found for GH_ORG 'missing-org'")
expect(output).not.toContain('SYNC_RESULT')
} else {
expect(output).toContain('SYNC_RESULT ' + JSON.stringify({ nop, owner: 'test-org', installation: 2 }))
expect(output).toContain('Syncing installation 2 on account test-org')
}
if (scenario === 'success') {
expect(output).toContain('Full sync completed successfully.')
} else {
expect(output).not.toContain('Full sync completed successfully.')
}
if (scenario === 'sync-errors') {
expect(output).toContain('Errors occurred during full sync.')
}
})
})

// Run both real entrypoints in a child process, replacing only external clients
// and Settings work. No App credentials or caller environment reach the child.
Expand All @@ -19,6 +121,7 @@ const script = `
rest: { apps: { listInstallations: { endpoint: { merge: x => x } } } }
}
const robot = {
ready: async () => robot,
on: () => {},
log: { trace: () => {}, debug: () => {}, info: console.log, error: console.error },
auth: async id => {
Expand Down
Loading
Loading