diff --git a/lib/settings.js b/lib/settings.js index 44ce6da1..9e7c6852 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -694,6 +694,9 @@ class Settings { settings.subOrgConfigs = await settings.getSubOrgConfigs() settings.trackChangedReposFromSubOrgConfigs() + // Sync org-level rulesets once before processing repos/suborgs + await settings.syncOrgLevelRulesets() + // Identify repos removed from suborg targeting due to targeting rule // changes in the suborg config file. These repos need processing so // their suborg-applied settings (e.g. rulesets) are cleaned up. @@ -744,7 +747,9 @@ class Settings { settings.subOrgConfigMap = [suborg] settings.suborgChange = !!suborg await settings.loadConfigs() - await settings.updateAll() + await settings.eachRepositoryRepos(settings.github, settings.log).then(res => { + settings.appendToResults(res) + }) } // Sync app installations for affected apps (delta mode) @@ -1125,7 +1130,10 @@ class Settings { const pluginSection = res.plugin ? res.plugin.toLowerCase() : null if (isOrgLevel && pluginSection === 'rulesets') { - // Org-level rulesets: keep only rulesets whose definition changed. + // Org-level rulesets: keep if top-level rulesets changed OR any suborg changed + if (res.fromSubOrg && this.changedRepoNames && this.changedRepoNames.size > 0) { + return true + } const changedNames = getChangedEntryNames(this.baseConfig.rulesets, this.config.rulesets) if (changedNames.size === 0) return false const filtered = filterActionByChangedNames(res.action, changedNames) @@ -1514,24 +1522,6 @@ class Settings { const stripMap = this.computeStripMap() const additiveSet = this.normalizeAdditivePlugins() - const rulesetsConfig = applyCentralizedBypassActors(this.config.rulesets, this.config.centralized_ruleset_bypass_actors) - if (rulesetsConfig) { - if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) { - this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin") - this.emitDisableSkip('rulesets') - } else { - const RulesetsPlugin = Settings.PLUGINS.rulesets - const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, rulesetsConfig, this.log, this.errors, SCOPE.ORG) - rulesetsPlugin.additive = additiveSet.has('rulesets') - await rulesetsPlugin.sync().then(res => { - if (this.nop && Array.isArray(res)) { - res.forEach(r => { if (r) r.repo = `${this.repo.owner} (org)` }) - } - this.appendToResults(res) - }) - } - } - const customRepositoryRolesConfig = this.config.custom_repository_roles if (customRepositoryRolesConfig) { if (this.isPluginDisabledAnywhere(stripMap, 'custom_repository_roles')) { @@ -2156,14 +2146,111 @@ class Settings { } } + // Resolves whether a suborg's rulesets should be applied as one shared + // org-level ruleset ('org') or per-repo ('repo', the original behavior). + // Precedence: the suborg's own `ruleset_scope` wins if set; otherwise falls + // back to the org-wide default `ruleset_scope` in org-settings.yml (admin- + // team controlled); otherwise defaults to 'repo' for backward compatibility. + // 'org' scope requires `suborgproperties` to build the repository_property + // filter from - without it, falls back to 'repo' so rulesets from suborgs + // matched via suborgteams/suborgrepos aren't silently dropped. + getEffectiveRulesetScope (subOrgData) { + const requested = (subOrgData && subOrgData.ruleset_scope) || (this.config && this.config.ruleset_scope) || 'repo' + if (requested !== 'repo' && requested !== 'org') { + this.log.warn(`Invalid ruleset_scope value '${requested}', defaulting to 'repo'`) + return 'repo' + } + if (requested === 'org' && (!subOrgData || !Array.isArray(subOrgData.suborgproperties) || subOrgData.suborgproperties.length === 0)) { + return 'repo' + } + return requested + } + async updateAll () { // this.subOrgConfigs = this.subOrgConfigs || await this.getSubOrgConfigs(this.github, this.repo, this.log) // this.repoConfigs = this.repoConfigs || await this.getRepoConfigs(this.github, this.repo, this.log) + await this.syncOrgLevelRulesets() return this.eachRepositoryRepos(this.github, this.log).then(res => { this.appendToResults(res) }) } + // Applies ALL org-level rulesets in ONE Rulesets.sync() pass: both + // org-settings.yml's own top-level `rulesets:` AND every suborg's + // ruleset_scope:'org' entries (via repository_property filters). This MUST + // be a single pass - the Rulesets plugin's find()/diff (scope 'org') sees + // every org-level ruleset currently on GitHub, so a pass aware of only a + // subset of the desired state (e.g. only org-settings.yml's list, or only + // one suborg's) would delete the other subset's rulesets as "no longer + // wanted". Called from updateAll(), which every sync path (syncAll, + // syncSubOrgs, syncSelectedRepos) routes through. Always reads every + // suborg file (not just this.subOrgConfigMap, which may be restricted to + // a single changed suborg) so the desired-state computation stays + // complete regardless of which sync path triggered it. + async syncOrgLevelRulesets () { + const stripMap = this.computeStripMap() + if (this.isPluginDisabledAnywhere(stripMap, 'rulesets')) { + this.log.debug("disable_plugins: skipping org-level 'rulesets' plugin") + this.emitDisableSkip('rulesets') + return + } + + const centralizedBypassActors = this.config.centralized_ruleset_bypass_actors + const entries = [...applyCentralizedBypassActors(this.config.rulesets || [], centralizedBypassActors)] + let hasOrgRulesetConfig = Array.isArray(this.config.rulesets) + + const overridePaths = await this.getSubOrgConfigMap() + if (Array.isArray(overridePaths)) { + for (const override of overridePaths) { + const data = await this.loadYaml(override.path) + if (!data) continue + + const sources = { suborg: data } + this.applyStrips(stripMap, sources, null) + const strippedData = sources.suborg + + if (!strippedData || this.getEffectiveRulesetScope(strippedData) !== 'org' || !Array.isArray(strippedData.rulesets)) continue + hasOrgRulesetConfig = true + + const propertyIncludes = strippedData.suborgproperties.map(filter => { + const [name] = Object.keys(filter) + return { name, source: 'custom', property_values: [String(filter[name])] } + }) + + const scopedEntries = applyCentralizedBypassActors(strippedData.rulesets, centralizedBypassActors).map(entry => { + const cloned = this.mergeDeep.mergeDeep({}, entry) + // Suffix with the suborg file so identically-named rulesets from + // different suborgs don't collide in the org's shared ruleset namespace. + cloned.name = `${entry.name} [${override.name}]` + cloned.conditions = cloned.conditions || {} + delete cloned.conditions.repository_name + delete cloned.conditions.repository_id + cloned.conditions.repository_property = { include: propertyIncludes, exclude: [] } + return cloned + }) + entries.push(...scopedEntries) + } + } + + if (!hasOrgRulesetConfig) return + + const additiveSet = this.normalizeAdditivePlugins() + const RulesetsPlugin = Settings.PLUGINS.rulesets + const rulesetsPlugin = new RulesetsPlugin(this.nop, this.github, this.repo, entries, this.log, this.errors, SCOPE.ORG) + rulesetsPlugin.additive = additiveSet.has('rulesets') + await rulesetsPlugin.sync().then(res => { + if (this.nop && Array.isArray(res)) { + res.forEach(r => { + if (r) { + r.repo = `${this.repo.owner} (org)` + r.fromSubOrg = true + } + }) + } + this.appendToResults(res) + }) + } + async updateChangedRepoConfigs (changedRepos = []) { if (!Array.isArray(changedRepos) || changedRepos.length === 0) return @@ -2371,7 +2458,25 @@ class Settings { suborg: this.cloneAndStripDisableMeta(subOrgOverrideConfig), repo: this.cloneAndStripDisableMeta(repoOverrideConfig) } + + // Check if suborg has rulesets before applyStrips potentially removes it + const suborgHadRulesets = sources.suborg && + Object.prototype.hasOwnProperty.call(sources.suborg, 'rulesets') + this.applyStrips(stripMap, sources, repoName) + + // ruleset_scope: 'org' suborgs get one shared org-level ruleset (see + // syncOrgLevelRulesets), so exclude them here to avoid also + // creating a duplicate per-repo ruleset for every matched repo. + // If migrating from 'repo' to 'org' scope, trigger cleanup by injecting + // an empty rulesets array so the plugin removes old repo-scoped entries. + // This must happen AFTER applyStrips so that if the user is migrating + // from repo to org scope and emptying/removing the rulesets list in the + // same change, the empty array persists to trigger cleanup (unless the + // plugin was explicitly disabled via disable_plugins). + if (sources.suborg && this.getEffectiveRulesetScope(sources.suborg) === 'org' && suborgHadRulesets) { + sources.suborg.rulesets = [] + } const overrideConfig = this.mergeDeep.mergeDeep({}, sources.org, sources.suborg, sources.repo) diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 4beccd3b..0ce85dd2 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -4,6 +4,15 @@ "description": "Schema for .github/settings.yml — org-level safe-settings configuration", "type": "object", "properties": { + "ruleset_scope": { + "type": "string", + "description": "Default scope for rulesets in suborg files. 'repo' (default) creates individual repository-scoped rulesets. 'org' creates organization-scoped rulesets with repository_property targeting. Can be overridden per suborg.", + "enum": [ + "repo", + "org" + ], + "default": "repo" + }, "repositories": { "description": "Repository settings", "allOf": [ diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json index b75780ff..eb550aed 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -4,6 +4,1214 @@ "description": "Schema for .github/suborgs/*.yml — suborg-level safe-settings configuration. Defines which repos belong to the suborg and what settings to apply.", "type": "object", "properties": { + "ruleset_scope": { + "type": "string", + "description": "Scope for rulesets in this suborg. 'repo' (default) creates individual repository-scoped rulesets for each matched repo. 'org' creates a single organization-scoped ruleset using repository_property targeting.", + "enum": [ + "repo", + "org" + ], + "default": "repo" + }, + "rulesets": { + "type": "array", + "description": "Rulesets to apply to repositories in this suborg", + "items": { + "description": "A ruleset entry", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the ruleset." + }, + "target": { + "type": "string", + "description": "The target of the ruleset", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "default": "branch" + }, + "enforcement": { + "type": "string", + "description": "The enforcement level of the ruleset. `evaluate` allows admins to test rules before enforcing them. Admins can view insights on the Rule Insights page (`evaluate` is only available with GitHub Enterprise).", + "enum": [ + "disabled", + "active", + "evaluate" + ] + }, + "bypass_actors": { + "type": "array", + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "title": "Repository Ruleset Bypass Actor", + "type": "object", + "description": "An actor that can bypass rules in a ruleset", + "required": [ + "actor_type" + ], + "properties": { + "actor_id": { + "type": "integer", + "nullable": true, + "description": "The ID of the actor that can bypass a ruleset. Required for `Integration`, `RepositoryRole`, `Team`, and `User` actor types. If `actor_type` is `OrganizationAdmin`, `actor_id` is ignored. If `actor_type` is `DeployKey`, this should be null. `OrganizationAdmin` is not applicable for personal repositories." + }, + "actor_type": { + "type": "string", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey", + "User" + ], + "description": "The type of actor that can bypass a ruleset." + }, + "bypass_mode": { + "type": "string", + "description": "When the specified actor can bypass the ruleset. `pull_request` means that an actor can only bypass rules on pull requests. `pull_request` is not applicable for the `DeployKey` actor type. Also, `pull_request` is only applicable to branch rulesets. When `bypass_mode` is `exempt`, rules will not be run for that actor and a bypass audit entry will not be created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "default": "always" + }, + "name": { + "type": "string", + "description": "Human-friendly alternative to `actor_id`. The team slug, username, GitHub App slug, or repository role name (resolved using `actor_type`). Cannot be combined with `actor_id`." + } + } + } + }, + "conditions": { + "title": "Organization ruleset conditions", + "type": "object", + "description": "Conditions for an organization ruleset.\nThe branch and tag rulesets conditions object should contain both `repository_name` and `ref_name` properties, or both `repository_id` and `ref_name` properties, or both `repository_property` and `ref_name` properties.\nThe push rulesets conditions object does not require the `ref_name` property.\nFor repository policy rulesets, the conditions object should only contain the `repository_name`, the `repository_id`, or the `repository_property`.", + "oneOf": [ + { + "type": "object", + "title": "repository_name_and_ref_name", + "description": "Conditions to target repositories by name and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository names", + "type": "object", + "description": "Parameters for a repository name condition", + "properties": { + "repository_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of repository names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~ALL` to include all repositories.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of repository names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + }, + "protected": { + "type": "boolean", + "description": "Whether renaming of target repositories is prevented." + } + } + } + }, + "required": [ + "repository_name" + ] + } + ] + }, + { + "type": "object", + "title": "repository_id_and_ref_name", + "description": "Conditions to target repositories by id and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository IDs", + "type": "object", + "description": "Parameters for a repository ID condition", + "properties": { + "repository_id": { + "type": "object", + "properties": { + "repository_ids": { + "type": "array", + "description": "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", + "items": { + "type": "integer" + } + } + } + } + }, + "required": [ + "repository_id" + ] + } + ] + }, + { + "type": "object", + "title": "repository_property_and_ref_name", + "description": "Conditions to target repositories by property and refs by name", + "allOf": [ + { + "title": "Repository ruleset conditions for ref names", + "type": "object", + "description": "Parameters for a repository ruleset ref name condition", + "properties": { + "ref_name": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "description": "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + "items": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Repository ruleset conditions for repository properties", + "type": "object", + "description": "Parameters for a repository property condition", + "properties": { + "repository_property": { + "type": "object", + "properties": { + "include": { + "type": "array", + "description": "The repository properties and values to include. All of these properties must match for the condition to pass.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + }, + "exclude": { + "type": "array", + "description": "The repository properties and values to exclude. The condition will not pass if any of these properties match.", + "items": { + "title": "Repository ruleset property targeting definition", + "type": "object", + "description": "Parameters for a targeting a repository property", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository property to target" + }, + "property_values": { + "type": "array", + "description": "The values to match for the repository property", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The source of the repository property. Defaults to 'custom' if not specified.", + "enum": [ + "custom", + "system" + ] + } + }, + "required": [ + "name", + "property_values" + ] + } + } + } + } + }, + "required": [ + "repository_property" + ] + } + ] + } + ] + }, + "rules": { + "type": "array", + "description": "An array of rules within the ruleset.", + "items": { + "title": "Repository Rule", + "type": "object", + "description": "A repository rule.", + "oneOf": [ + { + "title": "creation", + "description": "Only allow users with bypass permission to create matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "creation" + ] + } + } + }, + { + "title": "update", + "description": "Only allow users with bypass permission to update matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "parameters": { + "type": "object", + "properties": { + "update_allows_fetch_and_merge": { + "type": "boolean", + "description": "Branch can pull changes from its upstream repository" + } + }, + "required": [ + "update_allows_fetch_and_merge" + ] + } + } + }, + { + "title": "deletion", + "description": "Only allow users with bypass permissions to delete matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "deletion" + ] + } + } + }, + { + "title": "required_linear_history", + "description": "Prevent merge commits from being pushed to matching refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_linear_history" + ] + } + } + }, + { + "title": "required_deployments", + "description": "Choose which environments must be successfully deployed to before refs can be pushed into a ref that matches this rule.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_deployments" + ] + }, + "parameters": { + "type": "object", + "properties": { + "required_deployment_environments": { + "type": "array", + "description": "The environments that must be successfully deployed to before branches can be merged.", + "items": { + "type": "string" + } + } + }, + "required": [ + "required_deployment_environments" + ] + } + } + }, + { + "title": "required_signatures", + "description": "Commits pushed to matching refs must have verified signatures.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_signatures" + ] + } + } + }, + { + "title": "pull_request", + "description": "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "pull_request" + ] + }, + "parameters": { + "type": "object", + "properties": { + "allowed_merge_methods": { + "type": "array", + "description": "Array of allowed merge methods. Allowed values include `merge`, `squash`, and `rebase`. At least one option must be enabled.", + "items": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + } + }, + "dismiss_stale_reviews_on_push": { + "type": "boolean", + "description": "New, reviewable commits pushed will dismiss previous pull request review approvals." + }, + "dismissal_restriction": { + "title": "DismissalRestriction", + "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.", + "type": "object", + "properties": { + "allowed_actors": { + "type": "array", + "description": "Specify people, teams, or apps allowed to dismiss pull request reviews.", + "items": { + "title": "Actor", + "description": "An actor allowed to dismiss pull request reviews", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the actor that can dismiss reviews." + }, + "type": { + "type": "string", + "description": "The type of the actor", + "enum": [ + "User", + "Team", + "IntegrationInstallation", + "RepositoryRole" + ] + } + }, + "required": [ + "id", + "type" + ] + } + }, + "enabled": { + "type": "boolean", + "description": "Whether to restrict review dismissal to specific actors." + } + }, + "required": [ + "enabled" + ] + }, + "require_code_owner_review": { + "type": "boolean", + "description": "Require an approving review in pull requests that modify files that have a designated code owner." + }, + "require_last_push_approval": { + "type": "boolean", + "description": "Whether the most recent reviewable push must be approved by someone other than the person who pushed it." + }, + "required_approving_review_count": { + "type": "integer", + "description": "The number of approving reviews that are required before a pull request can be merged.", + "minimum": 0, + "maximum": 10 + }, + "required_review_thread_resolution": { + "type": "boolean", + "description": "All conversations on code must be resolved before a pull request can be merged." + }, + "required_reviewers": { + "type": "array", + "description": "> [!NOTE]\n> `required_reviewers` is in beta and subject to change.\n\nA collection of reviewers and associated file patterns. Each reviewer has a list of file patterns which determine the files that reviewer is required to review.", + "items": { + "title": "RequiredReviewerConfiguration", + "description": "A reviewing team, and file patterns describing which files they must approve changes to.", + "type": "object", + "properties": { + "file_patterns": { + "type": "array", + "description": "Array of file patterns. Pull requests which change matching files must be approved by the specified team. File patterns use fnmatch syntax.", + "items": { + "type": "string" + } + }, + "minimum_approvals": { + "type": "integer", + "description": "Minimum number of approvals required from the specified team. If set to zero, the team will be added to the pull request but approval is optional." + }, + "reviewer": { + "title": "Reviewer", + "description": "A required reviewing team", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the reviewer which must review changes to matching files." + }, + "type": { + "type": "string", + "description": "The type of the reviewer", + "enum": [ + "Team" + ] + }, + "slug": { + "type": "string", + "description": "Human-friendly alternative to `id`. The slug of the team that must review changes to matching files. Cannot be combined with `id`." + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "file_patterns", + "minimum_approvals", + "reviewer" + ] + } + } + }, + "required": [ + "dismiss_stale_reviews_on_push", + "require_code_owner_review", + "require_last_push_approval", + "required_approving_review_count", + "required_review_thread_resolution" + ] + } + } + }, + { + "title": "required_status_checks", + "description": "Choose which status checks must pass before the ref is updated. When enabled, commits must first be pushed to another ref where the checks pass.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "required_status_checks" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "required_status_checks": { + "type": "array", + "description": "Status checks that are required.", + "items": { + "title": "StatusCheckConfiguration", + "description": "Required status check", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The status check context name that must be present on the commit." + }, + "integration_id": { + "type": "integer", + "description": "The optional integration ID that this status check must originate from." + } + }, + "required": [ + "context" + ] + } + }, + "strict_required_status_checks_policy": { + "type": "boolean", + "description": "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled." + } + }, + "required": [ + "required_status_checks", + "strict_required_status_checks_policy" + ] + } + } + }, + { + "title": "non_fast_forward", + "description": "Prevent users with push access from force pushing to refs.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "non_fast_forward" + ] + } + } + }, + { + "title": "commit_message_pattern", + "description": "Parameters to be used for the commit_message_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_message_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "commit_author_email_pattern", + "description": "Parameters to be used for the commit_author_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "commit_author_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "committer_email_pattern", + "description": "Parameters to be used for the committer_email_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "committer_email_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "branch_name_pattern", + "description": "Parameters to be used for the branch_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "branch_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "tag_name_pattern", + "description": "Parameters to be used for the tag_name_pattern rule", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "tag_name_pattern" + ] + }, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "How this rule appears when configuring it." + }, + "negate": { + "type": "boolean", + "description": "If true, the rule will fail if the pattern matches." + }, + "operator": { + "type": "string", + "description": "The operator to use for matching.", + "enum": [ + "starts_with", + "ends_with", + "contains", + "regex" + ] + }, + "pattern": { + "type": "string", + "description": "The pattern to match with." + } + }, + "required": [ + "operator", + "pattern" + ] + } + } + }, + { + "title": "file_path_restriction", + "description": "Prevent commits that include changes in specified file and folder paths from being pushed to the commit graph. This includes absolute paths that contain file names.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_path_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_paths": { + "type": "array", + "description": "The file paths that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_paths" + ] + } + } + }, + { + "title": "max_file_path_length", + "description": "Prevent commits that include file paths that exceed the specified character limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_path_length" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_path_length": { + "type": "integer", + "description": "The maximum amount of characters allowed in file paths.", + "minimum": 1, + "maximum": 32767 + } + }, + "required": [ + "max_file_path_length" + ] + } + } + }, + { + "title": "file_extension_restriction", + "description": "Prevent commits that include files with specified file extensions from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "file_extension_restriction" + ] + }, + "parameters": { + "type": "object", + "properties": { + "restricted_file_extensions": { + "type": "array", + "description": "The file extensions that are restricted from being pushed to the commit graph.", + "items": { + "type": "string" + } + } + }, + "required": [ + "restricted_file_extensions" + ] + } + } + }, + { + "title": "max_file_size", + "description": "Prevent commits with individual files that exceed the specified limit from being pushed to the commit graph.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "max_file_size" + ] + }, + "parameters": { + "type": "object", + "properties": { + "max_file_size": { + "type": "integer", + "description": "The maximum file size allowed in megabytes. This limit does not apply to Git Large File Storage (Git LFS).", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "max_file_size" + ] + } + } + }, + { + "title": "workflows", + "description": "Require all changes made to a targeted branch to pass the specified workflows before they can be merged.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "workflows" + ] + }, + "parameters": { + "type": "object", + "properties": { + "do_not_enforce_on_create": { + "type": "boolean", + "description": "Allow repositories and branches to be created if a check would otherwise prohibit it." + }, + "workflows": { + "type": "array", + "description": "Workflows that must pass for this rule to pass.", + "items": { + "title": "WorkflowFileReference", + "description": "A workflow that must run for this rule to pass", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to the workflow file" + }, + "ref": { + "type": "string", + "description": "The ref (branch or tag) of the workflow file to use" + }, + "repository_id": { + "type": "integer", + "description": "The ID of the repository where the workflow is defined" + }, + "sha": { + "type": "string", + "description": "The commit SHA of the workflow file to use" + } + }, + "required": [ + "path", + "repository_id" + ] + } + } + }, + "required": [ + "workflows" + ] + } + } + }, + { + "title": "code_scanning", + "description": "Choose which tools must provide code scanning results before the reference is updated. When configured, code scanning must be enabled and have results for both the commit and the reference being updated.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "code_scanning" + ] + }, + "parameters": { + "type": "object", + "properties": { + "code_scanning_tools": { + "type": "array", + "description": "Tools that must provide code scanning results for this rule to pass.", + "items": { + "title": "CodeScanningTool", + "description": "A tool that must provide code scanning results for this rule to pass.", + "type": "object", + "properties": { + "alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise alerts block a reference update. For more information on alert severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "errors", + "errors_and_warnings", + "all" + ] + }, + "security_alerts_threshold": { + "type": "string", + "description": "The severity level at which code scanning results that raise security alerts block a reference update. For more information on security severity levels, see \"[About code scanning alerts](https://docs.github.com/code-security/code-scanning/managing-code-scanning-alerts/about-code-scanning-alerts#about-alert-severity-and-security-severity-levels).\"", + "enum": [ + "none", + "critical", + "high_or_higher", + "medium_or_higher", + "all" + ] + }, + "tool": { + "type": "string", + "description": "The name of a code scanning tool" + } + }, + "required": [ + "alerts_threshold", + "security_alerts_threshold", + "tool" + ] + } + } + }, + "required": [ + "code_scanning_tools" + ] + } + } + }, + { + "title": "copilot_code_review", + "description": "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review and their premium requests quota has not reached the limit.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "copilot_code_review" + ] + }, + "parameters": { + "type": "object", + "properties": { + "review_draft_pull_requests": { + "type": "boolean", + "description": "Copilot automatically reviews draft pull requests before they are marked as ready for review." + }, + "review_on_push": { + "type": "boolean", + "description": "Copilot automatically reviews each new push to the pull request." + } + } + } + } + } + ] + } + } + }, + "required": [ + "name", + "enforcement" + ] + } + }, "suborgrepos": { "type": "array", "description": "Glob patterns matching repository names. Repos whose names match any pattern are included in this suborg.", diff --git a/schema/settings.json b/schema/settings.json index 71eec48a..3da43111 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -4,6 +4,12 @@ "description": "Schema for .github/settings.yml — org-level safe-settings configuration", "type": "object", "properties": { + "ruleset_scope": { + "type": "string", + "description": "Default scope for rulesets in suborg files. 'repo' (default) creates individual repository-scoped rulesets. 'org' creates organization-scoped rulesets with repository_property targeting. Can be overridden per suborg.", + "enum": ["repo", "org"], + "default": "repo" + }, "repositories": { "$ref": "#/$defs/RepositorySettings" }, diff --git a/schema/suborgs.json b/schema/suborgs.json index 241d9839..9c6372f4 100644 --- a/schema/suborgs.json +++ b/schema/suborgs.json @@ -4,6 +4,19 @@ "description": "Schema for .github/suborgs/*.yml — suborg-level safe-settings configuration. Defines which repos belong to the suborg and what settings to apply.", "type": "object", "properties": { + "ruleset_scope": { + "type": "string", + "description": "Scope for rulesets in this suborg. 'repo' (default) creates individual repository-scoped rulesets for each matched repo. 'org' creates a single organization-scoped ruleset using repository_property targeting.", + "enum": ["repo", "org"], + "default": "repo" + }, + "rulesets": { + "type": "array", + "description": "Rulesets to apply to repositories in this suborg", + "items": { + "$ref": "#/$defs/RulesetSettings" + } + }, "suborgrepos": { "type": "array", "description": "Glob patterns matching repository names. Repos whose names match any pattern are included in this suborg.",