Skip to content
Draft
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,29 @@ repos:

For more information on ruleset configuration, see the [GitHub Rulesets documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets).

### Force Sync Rulesets

By default, syncing rulesets will create or update the specified ruleset by name, but will not delete other rulesets that may exist in the repository. To force sync and delete rulesets that don't match the one being synced, use the `force-sync-rulesets` parameter:

```yml
- name: Force Sync Repository Rulesets
uses: joshjohanning/bulk-github-repo-settings-sync-action@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
repositories-file: 'repos.yml'
rulesets-file: './config/rulesets/ci-ruleset.json'
force-sync-rulesets: true
```

**Behavior with `force-sync-rulesets: true`:**

- Creates the ruleset if it doesn't exist
- Updates the ruleset if a ruleset with the same name already exists
- **Deletes all other rulesets that don't match the synced ruleset name**
- In dry-run mode, shows which rulesets would be deleted without actually deleting them

**Use case:** This is useful when you rename a ruleset and want to ensure only the new ruleset exists, or when you want to enforce that repositories have exactly one specific ruleset configuration.

### Organization-wide Updates

```yml
Expand Down Expand Up @@ -242,6 +265,7 @@ Output shows what would change:
| `dependabot-yml` | Path to a dependabot.yml file to sync to `.github/dependabot.yml` in target repositories | No | - |
| `dependabot-pr-title` | Title for pull requests when updating dependabot.yml | No | `chore: update dependabot.yml` |
| `rulesets-file` | Path to a JSON file containing repository ruleset configuration to sync to target repositories | No | - |
| `force-sync-rulesets` | Delete rulesets that do not match the synced ruleset (force sync to only have the rulesets being synced) | No | `false` |
| `dry-run` | Preview changes without applying them (logs what would be changed) | No | `false` |

\* Either `repositories` or `repositories-file` must be provided
Expand Down
185 changes: 174 additions & 11 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const mockOctokit = {
getRepoRulesets: jest.fn(),
getRepoRuleset: jest.fn(),
createRepoRuleset: jest.fn(),
updateRepoRuleset: jest.fn()
updateRepoRuleset: jest.fn(),
deleteRepoRuleset: jest.fn()
},
codeScanning: {
updateDefaultSetup: jest.fn(),
Expand Down Expand Up @@ -1213,7 +1214,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
data: { id: 456, name: 'ci' }
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('updated');
Expand Down Expand Up @@ -1260,7 +1261,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
data: existingRuleset
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('unchanged');
Expand All @@ -1282,7 +1283,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({ data: [] });

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, true);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('would-create');
Expand Down Expand Up @@ -1314,7 +1315,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
data: existingRuleset
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, true);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('would-update');
Expand All @@ -1325,7 +1326,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
});

test('should handle invalid repository format', async () => {
const result = await syncRepositoryRuleset(mockOctokit, 'invalid-repo-format', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'invalid-repo-format', './ruleset.json', false, false);

expect(result.success).toBe(false);
expect(result.error).toContain('Invalid repository format');
Expand All @@ -1336,7 +1337,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
throw new Error('ENOENT: no such file or directory');
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './nonexistent.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './nonexistent.json', false, false);

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to read or parse ruleset file');
Expand All @@ -1345,7 +1346,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
test('should handle invalid JSON in ruleset file', async () => {
mockFs.readFileSync.mockReturnValue('{ invalid json }');

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './invalid.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './invalid.json', false, false);

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to read or parse ruleset file');
Expand All @@ -1360,7 +1361,7 @@ describe('Bulk GitHub Repository Settings Action', () => {

mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(false);
expect(result.error).toContain('Ruleset configuration must include a "name" field');
Expand All @@ -1377,7 +1378,7 @@ describe('Bulk GitHub Repository Settings Action', () => {
mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockRejectedValue(new Error('API rate limit exceeded'));

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(false);
expect(result.error).toContain('Failed to sync ruleset');
Expand All @@ -1399,10 +1400,172 @@ describe('Bulk GitHub Repository Settings Action', () => {
data: { id: 123, name: 'ci' }
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false);
const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('created');
});

test('should delete non-matching rulesets when force-sync is enabled', async () => {
const rulesetConfig = {
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
};

const existingRulesets = [
{ id: 123, name: 'ci' },
{ id: 456, name: 'ci2' },
{ id: 789, name: 'old-ruleset' }
];

mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({
data: existingRulesets
});
mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({
data: {
id: 123,
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
}
});
mockOctokit.rest.repos.deleteRepoRuleset.mockResolvedValue({});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('unchanged');
expect(result.deletedRulesets).toHaveLength(2);
expect(result.deletedRulesets[0].name).toBe('ci2');
expect(result.deletedRulesets[0].deleted).toBe(true);
expect(result.deletedRulesets[1].name).toBe('old-ruleset');
expect(result.deletedRulesets[1].deleted).toBe(true);
expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledTimes(2);
expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
ruleset_id: 456
});
expect(mockOctokit.rest.repos.deleteRepoRuleset).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
ruleset_id: 789
});
});

test('should handle force-sync in dry-run mode', async () => {
const rulesetConfig = {
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
};

const existingRulesets = [
{ id: 123, name: 'ci' },
{ id: 456, name: 'ci2' }
];

mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({
data: existingRulesets
});
mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({
data: {
id: 123,
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
}
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, true);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('unchanged');
expect(result.deletedRulesets).toHaveLength(1);
expect(result.deletedRulesets[0].name).toBe('ci2');
expect(result.deletedRulesets[0].wouldDelete).toBe(true);
expect(result.deletedRulesets[0].deleted).toBe(false);
expect(mockOctokit.rest.repos.deleteRepoRuleset).not.toHaveBeenCalled();
});

test('should not delete rulesets when force-sync is disabled', async () => {
const rulesetConfig = {
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
};

const existingRulesets = [
{ id: 123, name: 'ci' },
{ id: 456, name: 'ci2' }
];

mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({
data: existingRulesets
});
mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({
data: {
id: 123,
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
}
});

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', false, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('unchanged');
expect(result.deletedRulesets).toBeUndefined();
expect(mockOctokit.rest.repos.deleteRepoRuleset).not.toHaveBeenCalled();
});

test('should handle deletion errors gracefully when force-syncing', async () => {
const rulesetConfig = {
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
};

const existingRulesets = [
{ id: 123, name: 'ci' },
{ id: 456, name: 'ci2' }
];

mockFs.readFileSync.mockReturnValue(JSON.stringify(rulesetConfig));
mockOctokit.rest.repos.getRepoRulesets.mockResolvedValue({
data: existingRulesets
});
mockOctokit.rest.repos.getRepoRuleset.mockResolvedValue({
data: {
id: 123,
name: 'ci',
target: 'branch',
enforcement: 'active',
rules: [{ type: 'deletion' }]
}
});
mockOctokit.rest.repos.deleteRepoRuleset.mockRejectedValue(new Error('Permission denied'));

const result = await syncRepositoryRuleset(mockOctokit, 'owner/repo', './ruleset.json', true, false);

expect(result.success).toBe(true);
expect(result.ruleset).toBe('unchanged');
expect(result.deletedRulesets).toHaveLength(1);
expect(result.deletedRulesets[0].name).toBe('ci2');
expect(result.deletedRulesets[0].deleted).toBe(false);
expect(result.deletedRulesets[0].error).toBe('Permission denied');
});
});
});
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ inputs:
rulesets-file:
description: 'Path to a JSON file containing repository ruleset configuration to sync to target repositories'
required: false
force-sync-rulesets:
description: 'Delete rulesets that do not match the synced ruleset (force sync to only have the rulesets being synced)'
required: false
default: 'false'
dry-run:
description: 'Preview changes without applying them (logs what would be changed)'
required: false
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bulk-github-repo-settings-sync-action",
"description": "Update repository settings in bulk for multiple GitHub repositories",
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"author": {
"name": "Josh Johanning",
Expand Down
Loading