Skip to content

Commit d39626b

Browse files
authored
Merge branch 'main-enterprise' into pull-request-full-context
2 parents 7764b0b + 67c29c5 commit d39626b

File tree

8 files changed

+467
-226
lines changed

8 files changed

+467
-226
lines changed

README.md

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
# GitHub Safe-Settings
1+
# 🛡️ GitHub Safe-Settings
22

33
[![Create a release](https://github.com/github/safe-settings/actions/workflows/create-release.yml/badge.svg)](https://github.com/github/safe-settings/actions/workflows/create-release.yml)
4+
[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
5+
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/)
6+
7+
> **Policy-as-Code for GitHub Organizations**
8+
> Centrally manage and enforce repository settings, branch protections, teams, and more across your entire GitHub organization.
49
510
`Safe-settings` – an app to manage policy-as-code and apply repository settings across an organization.
611

@@ -29,8 +34,121 @@
2934

3035
> [!NOTE]
3136
> The `suborg` and `repo` level settings directory structure cannot be customized.
32-
>
3337
38+
## 🚀 Quick Start
39+
40+
### 1. **Deploy Safe-Settings**
41+
42+
Choose your preferred deployment method:
43+
44+
- **🌟 AWS Lambda (**: Use the [SafeSettings-Template](https://github.com/bheemreddy181/SafeSettings-Template) for production-ready deployment with Docker containers, GitHub Actions CI/CD, and comprehensive testing
45+
- **🐳 Docker**: Deploy using Docker containers locally or in your infrastructure
46+
- **☁️ Cloud Platforms**: Deploy to Heroku, Glitch, or Kubernetes
47+
48+
👉 **[View all deployment options →](docs/deploy.md)**
49+
50+
### 2. **Create Admin Repository**
51+
52+
Create an `admin` repository in your organization to store all configuration files:
53+
54+
```bash
55+
# Create admin repo in your organization
56+
gh repo create your-org/admin --private
57+
```
58+
59+
### 3. **Configure Settings Structure**
60+
61+
Set up your configuration files in the admin repository:
62+
63+
```
64+
admin/
65+
├── .github/
66+
│ ├── settings.yml # Organization-wide settings
67+
│ ├── suborgs/ # Sub-organization settings
68+
│ │ ├── frontend-team.yml
69+
│ │ └── backend-team.yml
70+
│ └── repos/ # Repository-specific settings
71+
│ ├── api-service.yml
72+
│ └── web-app.yml
73+
```
74+
75+
### 4. **Install GitHub App**
76+
77+
Install the Safe-Settings GitHub App in your organization with the required permissions.
78+
79+
👉 **[Complete setup guide →](#how-to-use)**
80+
81+
## 📊 Visual Architecture
82+
83+
### Configuration Hierarchy
84+
85+
```mermaid
86+
graph TD
87+
A[Organization Settings<br/>.github/settings.yml] --> B[Sub-Organization Settings<br/>.github/suborgs/*.yml]
88+
B --> C[Repository Settings<br/>.github/repos/*.yml]
89+
90+
style A fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000
91+
style B fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#000
92+
style C fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px,color:#000
93+
```
94+
95+
**Precedence Order**: Repository > Sub-Organization > Organization
96+
97+
### Request Flow
98+
99+
```mermaid
100+
sequenceDiagram
101+
participant GH as GitHub
102+
participant SS as Safe-Settings
103+
participant AR as Admin Repo
104+
participant TR as Target Repos
105+
106+
Note over GH,TR: Webhook Event Processing
107+
108+
GH->>+SS: Webhook Event<br/>(push, repo created, etc.)
109+
SS->>SS: Validate Event Source
110+
SS->>+AR: Fetch Configuration Files<br/>(.github/settings.yml, suborgs/, repos/)
111+
AR-->>-SS: Return Config Files
112+
113+
SS->>SS: Merge Configurations<br/>(Org → Suborg → Repo)
114+
SS->>SS: Compare with Current<br/>GitHub Settings
115+
116+
alt Configuration Changes Detected
117+
SS->>+TR: Apply Settings<br/>(Branch Protection, Teams, etc.)
118+
TR-->>-SS: Confirm Changes
119+
SS->>GH: Create Check Run<br/>(Success/Failure)
120+
else No Changes Needed
121+
SS->>GH: Create Check Run<br/>(No Changes)
122+
end
123+
124+
SS-->>-GH: HTTP 200 Response
125+
126+
Note over GH,TR: Pull Request Validation (Dry-Run Mode)
127+
128+
GH->>+SS: PR Event<br/>(opened, synchronize)
129+
SS->>+AR: Fetch PR Changes<br/>(Modified Config Files)
130+
AR-->>-SS: Return Changed Configs
131+
132+
SS->>SS: Validate Changes<br/>(Dry-Run Mode)
133+
SS->>SS: Run Custom Validators<br/>(if configured)
134+
135+
alt Validation Passes
136+
SS->>GH: ✅ Check Success<br/>+ PR Comment (optional)
137+
else Validation Fails
138+
SS->>GH: ❌ Check Failure<br/>+ Error Details
139+
end
140+
141+
SS-->>-GH: HTTP 200 Response
142+
143+
Note over GH,TR: Scheduled Sync (Drift Prevention)
144+
145+
SS->>SS: Cron Trigger<br/>(if configured)
146+
SS->>+AR: Fetch All Configurations
147+
AR-->>-SS: Return All Configs
148+
SS->>+TR: Sync All Repositories<br/>(Prevent Drift)
149+
TR-->>-SS: Confirm Sync
150+
SS->>GH: Create Check Run<br/>(Sync Results)
151+
```
34152

35153
## How it works
36154

index.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,32 @@ module.exports = (robot, { getRouter }, Settings = require('./lib/settings')) =>
624624
return syncSettings(false, context)
625625
})
626626

627+
robot.on('repository.archived', async context => {
628+
const { payload } = context
629+
const { sender } = payload
630+
631+
if (sender.type === 'Bot') {
632+
robot.log.debug('Repository Archived by a Bot')
633+
return
634+
}
635+
robot.log.debug('Repository Archived by a Human')
636+
637+
return syncSettings(false, context)
638+
})
639+
640+
robot.on('repository.unarchived', async context => {
641+
const { payload } = context
642+
const { sender } = payload
643+
644+
if (sender.type === 'Bot') {
645+
robot.log.debug('Repository Unarchived by a Bot')
646+
return
647+
}
648+
robot.log.debug('Repository Unarchived by a Human')
649+
650+
return syncSettings(false, context)
651+
})
652+
627653
if (process.env.CRON) {
628654
/*
629655
# ┌────────────── second (optional)

lib/plugins/archive.js

Lines changed: 99 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,108 @@
1-
const NopCommand = require('../nopcommand');
2-
3-
function returnValue(shouldContinue, nop) {
4-
return { shouldContinue, nopCommands: nop };
5-
}
1+
const NopCommand = require('../nopcommand')
62

73
module.exports = class Archive {
8-
constructor(nop, github, repo, settings, log) {
9-
this.github = github;
10-
this.repo = repo;
11-
this.settings = settings;
12-
this.log = log;
13-
this.nop = nop;
4+
constructor (nop, github, repo, settings, log) {
5+
this.github = github
6+
this.repo = repo
7+
this.settings = settings
8+
this.log = log
9+
this.nop = nop
1410
}
1511

16-
// Returns true if plugin application should continue, false otherwise
17-
async sync() {
18-
// Fetch repository details using REST API
19-
const { data: repoDetails } = await this.github.repos.get({
12+
async getRepo () {
13+
try {
14+
const { data } = await this.github.repos.get({
2015
owner: this.repo.owner,
2116
repo: this.repo.repo
22-
});
23-
if (typeof this.settings?.archived !== 'undefined') {
24-
this.log.debug(`Checking if ${this.repo.owner}/${this.repo.repo} is archived`);
25-
26-
this.log.debug(`Repo ${this.repo.owner}/${this.repo.repo} is ${repoDetails.archived ? 'archived' : 'not archived'}`);
27-
28-
if (repoDetails.archived) {
29-
if (this.settings.archived) {
30-
this.log.debug(`Repo ${this.repo.owner}/${this.repo.repo} already archived, inform other plugins should not run.`);
31-
return returnValue(false);
32-
}
33-
else {
34-
this.log.debug(`Unarchiving ${this.repo.owner}/${this.repo.repo}`);
35-
if (this.nop) {
36-
return returnValue(true, [new NopCommand(this.constructor.name, this.repo, this.github.repos.update.endpoint(this.settings), 'will unarchive')]);
37-
}
38-
else {
39-
// Unarchive the repository using REST API
40-
const updateResponse = await this.github.repos.update({
41-
owner: this.repo.owner,
42-
repo: this.repo.repo,
43-
archived: false
44-
});
45-
this.log.debug(`Unarchive result ${JSON.stringify(updateResponse)}`);
46-
47-
return returnValue(true);
48-
}
49-
}
50-
}
51-
else {
52-
if (this.settings.archived) {
53-
this.log.debug(`Archiving ${this.repo.owner}/${this.repo.repo}`);
54-
if (this.nop) {
55-
return returnValue(false, [new NopCommand(this.constructor.name, this.repo, this.github.repos.update.endpoint(this.settings), 'will archive')]);
56-
}
57-
else {
58-
// Archive the repository using REST API
59-
const updateResponse = await this.github.repos.update({
60-
owner: this.repo.owner,
61-
repo: this.repo.repo,
62-
archived: true
63-
});
64-
this.log.debug(`Archive result ${JSON.stringify(updateResponse)}`);
65-
66-
return returnValue(false);
67-
}
68-
}
69-
else {
70-
this.log.debug(`Repo ${this.repo.owner}/${this.repo.repo} is not archived, ignoring.`);
71-
return returnValue(true);
72-
}
17+
})
18+
return data
19+
} catch (error) {
20+
if (error.status === 404 && !this.getDesiredArchiveState()) {
21+
return null
7322
}
74-
}
75-
else {
76-
if (repoDetails.archived) {
77-
this.log.debug(`Repo ${this.repo.owner}/${this.repo.repo} is archived, ignoring.`);
78-
return returnValue(false);
79-
}
80-
else {
81-
this.log.debug(`Repo ${this.repo.owner}/${this.repo.repo} is not archived, proceed as usual.`);
82-
return returnValue(true);
83-
}
23+
throw error
8424
}
8525
}
86-
};
26+
27+
async updateRepoArchiveStatus (archived) {
28+
const action = archived ? 'archive' : 'unarchive'
29+
30+
if (this.nop) {
31+
const change = { msg: 'Change found', additions: {}, modifications: { archived: action }, deletions: {} }
32+
return new NopCommand(
33+
this.constructor.name,
34+
this.repo,
35+
this.github.repos.update.endpoint(this.settings),
36+
change,
37+
'INFO'
38+
)
39+
}
40+
41+
const { data } = await this.github.repos.update({
42+
owner: this.repo.owner,
43+
repo: this.repo.repo,
44+
archived
45+
})
46+
47+
this.log.debug({ result: data }, `Repo ${this.repo.owner}/${this.repo.repo} ${action}d`)
48+
}
49+
50+
getDesiredArchiveState () {
51+
if (typeof this.settings?.archived === 'undefined') {
52+
return null
53+
}
54+
return typeof this.settings.archived === 'boolean'
55+
? this.settings.archived
56+
: this.settings.archived === 'true'
57+
}
58+
59+
shouldArchive (repository = this.repository) {
60+
const desiredState = this.getDesiredArchiveState()
61+
if (desiredState === null) return false
62+
return !repository.archived && desiredState
63+
}
64+
65+
shouldUnarchive (repository = this.repository) {
66+
const desiredState = this.getDesiredArchiveState()
67+
if (desiredState === null) return false
68+
return repository.archived && !desiredState
69+
}
70+
71+
isArchived () {
72+
return this.repository?.archived
73+
}
74+
75+
async getState () {
76+
this.repository = await this.getRepo()
77+
78+
return {
79+
isArchived: this.isArchived(),
80+
shouldArchive: this.shouldArchive(),
81+
shouldUnarchive: this.shouldUnarchive()
82+
}
83+
}
84+
85+
async sync () {
86+
this.repository = await this.getRepo()
87+
88+
const results = []
89+
90+
if (!this.repository) {
91+
this.log.warn(`Repo ${this.repo.owner}/${this.repo.repo} not found, skipping archive sync`)
92+
return results
93+
}
94+
95+
const shouldArchive = this.shouldArchive()
96+
const shouldUnarchive = this.shouldUnarchive()
97+
98+
if (!shouldArchive && !shouldUnarchive) {
99+
this.log.debug(`No archive changes needed for ${this.repo.owner}/${this.repo.repo}`)
100+
return results
101+
}
102+
103+
const archived = shouldArchive
104+
results.push(await this.updateRepoArchiveStatus(archived))
105+
106+
return results
107+
}
108+
}

lib/plugins/repository.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ const ignorableFields = [
3636
'org',
3737
'force_create',
3838
'auto_init',
39-
'repo'
39+
'repo',
40+
'archived'
4041
]
4142

4243
module.exports = class Repository extends ErrorStash {

0 commit comments

Comments
 (0)