Skip to content

Commit 5c6d69e

Browse files
authored
feat: enforce standard GitHub configuration for all repositories (#7)
Implement automated enforcement of standard GitHub repository settings across all worlddriven organization repositories to ensure consistency and support the democratic merge workflow. Changes: - Add standard repository settings (squash-only merge, branch cleanup) - Add standard branch protection ruleset (require PR, prevent force push) - Auto-apply settings during repository creation via auto_init - Check and enforce settings for existing repositories - Add branch protection rulesets automatically Configuration enforced: - Merge: Squash only (1 PR = 1 commit for fair voting) - Branch cleanup: Delete branches after merge - Branch protection: Require PR, prevent force push, prevent deletion - No bypass actors: True democracy for all This ensures all worlddriven repositories follow consistent practices that support the contribution-weighted voting system and maintain clean, auditable history.
1 parent 87517af commit 5c6d69e

1 file changed

Lines changed: 160 additions & 4 deletions

File tree

scripts/sync-repositories.js

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,51 @@ import { detectDrift } from './detect-drift.js';
1515
const GITHUB_API_BASE = 'https://api.github.com';
1616
const ORG_NAME = 'worlddriven';
1717

18+
/**
19+
* Standard repository settings applied to all worlddriven repositories
20+
* Enforces: squash-only merges, branch cleanup, democratic workflow
21+
*/
22+
const STANDARD_REPO_SETTINGS = {
23+
allow_squash_merge: true,
24+
allow_merge_commit: false,
25+
allow_rebase_merge: false,
26+
allow_auto_merge: false,
27+
delete_branch_on_merge: true,
28+
allow_update_branch: false,
29+
};
30+
31+
/**
32+
* Standard branch protection ruleset applied to default branch
33+
* Enforces: PR requirement, no force push, no deletion
34+
*/
35+
const STANDARD_BRANCH_RULESET = {
36+
name: 'Worlddriven Democratic Governance',
37+
target: 'branch',
38+
enforcement: 'active',
39+
conditions: {
40+
ref_name: {
41+
include: ['~DEFAULT_BRANCH'],
42+
exclude: [],
43+
},
44+
},
45+
rules: [
46+
{ type: 'deletion' },
47+
{ type: 'non_fast_forward' },
48+
{
49+
type: 'pull_request',
50+
parameters: {
51+
required_approving_review_count: 0,
52+
dismiss_stale_reviews_on_push: false,
53+
require_code_owner_review: false,
54+
require_last_push_approval: false,
55+
required_review_thread_resolution: false,
56+
allowed_merge_methods: ['squash'],
57+
},
58+
},
59+
],
60+
bypass_actors: [],
61+
};
62+
1863
/**
1964
* Create a repository in the GitHub organization
2065
*/
@@ -25,9 +70,12 @@ async function createRepository(token, repoData) {
2570
name: repoData.name,
2671
description: repoData.description,
2772
private: false,
73+
auto_init: true, // Creates initial README and main branch
2874
has_issues: true,
2975
has_projects: true,
3076
has_wiki: true,
77+
// Apply standard settings at creation
78+
...STANDARD_REPO_SETTINGS,
3179
};
3280

3381
const response = await fetch(url, {
@@ -49,6 +97,90 @@ async function createRepository(token, repoData) {
4997
return await response.json();
5098
}
5199

100+
/**
101+
* Create branch protection ruleset for a repository
102+
*/
103+
async function createBranchProtectionRuleset(token, repoName) {
104+
const url = `${GITHUB_API_BASE}/repos/${ORG_NAME}/${repoName}/rulesets`;
105+
106+
const response = await fetch(url, {
107+
method: 'POST',
108+
headers: {
109+
Authorization: `Bearer ${token}`,
110+
Accept: 'application/vnd.github+json',
111+
'X-GitHub-Api-Version': '2022-11-28',
112+
'Content-Type': 'application/json',
113+
},
114+
body: JSON.stringify(STANDARD_BRANCH_RULESET),
115+
});
116+
117+
if (!response.ok) {
118+
const error = await response.text();
119+
throw new Error(`GitHub API error (${response.status}): ${error}`);
120+
}
121+
122+
return await response.json();
123+
}
124+
125+
/**
126+
* Update repository settings to match standard configuration
127+
*/
128+
async function updateRepositorySettings(token, repoName) {
129+
const url = `${GITHUB_API_BASE}/repos/${ORG_NAME}/${repoName}`;
130+
131+
const response = await fetch(url, {
132+
method: 'PATCH',
133+
headers: {
134+
Authorization: `Bearer ${token}`,
135+
Accept: 'application/vnd.github+json',
136+
'X-GitHub-Api-Version': '2022-11-28',
137+
'Content-Type': 'application/json',
138+
},
139+
body: JSON.stringify(STANDARD_REPO_SETTINGS),
140+
});
141+
142+
if (!response.ok) {
143+
const error = await response.text();
144+
throw new Error(`GitHub API error (${response.status}): ${error}`);
145+
}
146+
147+
return await response.json();
148+
}
149+
150+
/**
151+
* Ensure repository has standard configuration
152+
* Updates settings and creates ruleset if missing
153+
*/
154+
async function ensureStandardConfiguration(token, repoName) {
155+
// Update repository settings to match standard
156+
await updateRepositorySettings(token, repoName);
157+
158+
// Check if ruleset exists
159+
const rulesetsUrl = `${GITHUB_API_BASE}/repos/${ORG_NAME}/${repoName}/rulesets`;
160+
const rulesetsResponse = await fetch(rulesetsUrl, {
161+
method: 'GET',
162+
headers: {
163+
Authorization: `Bearer ${token}`,
164+
Accept: 'application/vnd.github+json',
165+
'X-GitHub-Api-Version': '2022-11-28',
166+
},
167+
});
168+
169+
if (!rulesetsResponse.ok) {
170+
throw new Error(`Failed to fetch rulesets: ${rulesetsResponse.status}`);
171+
}
172+
173+
const rulesets = await rulesetsResponse.json();
174+
const existingRuleset = rulesets.find(
175+
(r) => r.name === STANDARD_BRANCH_RULESET.name
176+
);
177+
178+
if (!existingRuleset) {
179+
// Create new ruleset
180+
await createBranchProtectionRuleset(token, repoName);
181+
}
182+
}
183+
52184
/**
53185
* Update repository description
54186
*/
@@ -232,7 +364,7 @@ async function addInitializeActions(token, plan, actualRepos, desiredRepos) {
232364
/**
233365
* Generate sync plan from drift
234366
*/
235-
function generateSyncPlan(drift) {
367+
function generateSyncPlan(drift, desiredRepos) {
236368
// Protected repositories that should never be deleted
237369
const PROTECTED_REPOS = ['documentation', 'core', 'webapp'];
238370

@@ -243,6 +375,7 @@ function generateSyncPlan(drift) {
243375
updateDescription: 0,
244376
updateTopics: 0,
245377
initialize: 0,
378+
ensureSettings: 0,
246379
delete: 0,
247380
skip: 0,
248381
},
@@ -280,6 +413,19 @@ function generateSyncPlan(drift) {
280413
plan.summary.updateTopics++;
281414
}
282415

416+
// Ensure standard settings for all existing repos (not being created or deleted)
417+
for (const repo of desiredRepos) {
418+
// Skip repos being created (they get settings during creation)
419+
const isBeingCreated = drift.missing.some((r) => r.name === repo.name);
420+
if (!isBeingCreated) {
421+
plan.actions.push({
422+
type: 'ensure-settings',
423+
repo: repo.name,
424+
});
425+
plan.summary.ensureSettings++;
426+
}
427+
}
428+
283429
// Delete extra repos (unless protected)
284430
for (const repo of drift.extra) {
285431
if (PROTECTED_REPOS.includes(repo.name)) {
@@ -335,14 +481,18 @@ async function executeSyncPlan(token, plan, dryRun) {
335481
switch (action.type) {
336482
case 'create':
337483
result = await createRepository(token, action.data);
338-
// Create initial commit so repository can be forked
339-
await createInitialCommit(token, action.data.name, action.data.description);
484+
// Apply branch protection after creation (main branch now exists via auto_init)
485+
await createBranchProtectionRuleset(token, action.data.name);
340486
// After creating, set topics if they exist
341487
if (action.data.topics && action.data.topics.length > 0) {
342488
await updateRepositoryTopics(token, action.data.name, action.data.topics);
343489
}
344490
break;
345491

492+
case 'ensure-settings':
493+
result = await ensureStandardConfiguration(token, action.repo);
494+
break;
495+
346496
case 'initialize':
347497
result = await createInitialCommit(token, action.repo, action.description);
348498
break;
@@ -399,6 +549,7 @@ function formatSyncReport(plan, results, dryRun) {
399549
lines.push(`- Update descriptions: ${plan.summary.updateDescription}`);
400550
lines.push(`- Update topics: ${plan.summary.updateTopics}`);
401551
lines.push(`- Initialize (add first commit): ${plan.summary.initialize}`);
552+
lines.push(`- Ensure settings: ${plan.summary.ensureSettings}`);
402553
lines.push(`- Delete: ${plan.summary.delete}`);
403554
lines.push(`- Skip (protected): ${plan.summary.skip}`);
404555
lines.push('');
@@ -436,6 +587,11 @@ function formatSyncReport(plan, results, dryRun) {
436587
lines.push(` - Description: ${action.description}`);
437588
break;
438589

590+
case 'ensure-settings':
591+
lines.push(`- **Ensure settings** for \`${action.repo}\``);
592+
lines.push(` - Applied standard configuration`);
593+
break;
594+
439595
case 'delete':
440596
lines.push(`- **Delete** \`${action.repo}\``);
441597
if (action.data.description) {
@@ -509,7 +665,7 @@ async function main() {
509665

510666
// Generate sync plan
511667
console.error('📋 Generating sync plan...');
512-
const plan = generateSyncPlan(drift);
668+
const plan = generateSyncPlan(drift, desiredRepos);
513669

514670
// Check for empty repositories and add initialize actions
515671
console.error('🔍 Checking for empty repositories...');

0 commit comments

Comments
 (0)