diff --git a/README.md b/README.md
index b863751..8ee99b4 100644
--- a/README.md
+++ b/README.md
@@ -1012,6 +1012,30 @@ This sync is intentionally non-destructive: it creates or updates files present
Per-org overrides can be set in `orgs.yml` using the `dot-github-source-dir` and `dot-github-private-source-dir` keys.
+### Auto-creating missing `.github` / `.github-private` repos
+
+By default, if the target `.github` or `.github-private` repository doesn't exist, the action skips it with a warning. To have the action bootstrap missing repos before syncing, opt in with `create-missing-dot-github-repos: true`. Only repos with a configured source-dir are affected. Created repos use `auto_init: true` so the sync flow has a default branch to PR against.
+
+```yml
+- name: Sync .github repo files (auto-create missing)
+ uses: joshjohanning/bulk-github-org-settings-sync-action@v1
+ with:
+ github-token: ${{ secrets.ORG_ADMIN_TOKEN }}
+ organizations: 'my-org,my-other-org'
+ dot-github-source-dir: './dot-github-template'
+ dot-github-private-source-dir: './dot-github-private-template'
+ create-missing-dot-github-repos: true
+ # Defaults: dot-github-repo-visibility=public, dot-github-private-repo-visibility=private.
+ # EMU / restricted-GHEC orgs that disallow public repos must set this to 'internal'.
+ dot-github-repo-visibility: public
+ dot-github-private-repo-visibility: private
+```
+
+All three settings (`create-missing-dot-github-repos`, `dot-github-repo-visibility`, `dot-github-private-repo-visibility`) can also be set per-org in `orgs.yml`. Allowed visibility values: `public`, `private`, `internal`.
+
+> [!IMPORTANT]
+> Creating repositories requires `administration: write` on the GitHub App at the organization level, in addition to the existing `contents: write`. If `public` is rejected by the organization (e.g. Enterprise Managed Users or a restricted GHEC org), the action emits an actionable warning suggesting the appropriate repo-specific visibility setting: `dot-github-repo-visibility: internal` for `.github`, or `dot-github-private-repo-visibility: internal` for `.github-private`.
+
---
## Syncing Code Security Configurations
@@ -1215,62 +1239,65 @@ orgs:
## Action Inputs
-| Input | Description | Required | Default |
-| --------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------- | ----------------------- |
-| `github-token` | GitHub token for API access (requires `admin:org` scope) | Yes | |
-| `github-api-url` | GitHub API URL (e.g., `https://api.github.com` or `https://ghes.domain.com/api/v3`) | No | `${{ github.api_url }}` |
-| `organizations` | Comma-separated list of organization names | No | |
-| `organizations-file` | Path to YAML file containing organization settings configuration | No | |
-| `custom-properties-file` | Path to a YAML file defining custom property schemas | No | |
-| `delete-unmanaged-properties` | Delete custom properties not defined in the configuration file | No | `false` |
-| `issue-types-file` | Path to a YAML file defining issue type definitions | No | |
-| `delete-unmanaged-issue-types` | Delete issue types not defined in the configuration file | No | `false` |
-| `default-repository-permission` | Default permission for org members: `read`, `write`, `admin`, `none` | No | |
-| `members-can-create-repositories` | Whether members can create repositories | No | |
-| `members-can-create-public-repositories` | Whether members can create public repositories | No | |
-| `members-can-create-private-repositories` | Whether members can create private repositories | No | |
-| `members-can-create-internal-repositories` | Whether members can create internal repositories (GHEC/GHES only) | No | |
-| `members-can-fork-private-repositories` | Whether members can fork private repositories | No | |
-| `web-commit-signoff-required` | Whether web UI commits require signoff | No | |
-| `members-can-create-pages` | Whether members can create GitHub Pages sites | No | |
-| `members-can-create-public-pages` | Whether members can create public GitHub Pages sites | No | |
-| `members-can-create-private-pages` | Whether members can create private GitHub Pages sites | No | |
-| `members-can-invite-outside-collaborators` | Whether members can invite outside collaborators | No | |
-| `members-can-create-teams` | Whether members can create teams | No | |
-| `members-can-delete-repositories` | Whether members can delete repositories | No | |
-| `members-can-change-repo-visibility` | Whether members can change repository visibility | No | |
-| `members-can-delete-issues` | Whether members can delete issues | No | |
-| `default-repository-branch` | Default branch name for new repositories | No | |
-| `deploy-keys-enabled-for-repositories` | Whether deploy keys can be added to repositories | No | |
-| `readers-can-create-discussions` | Whether users with read access can create discussions | No | |
-| `members-can-view-dependency-insights` | Whether members can view dependency insights | No | |
-| `display-commenter-full-name-setting-enabled` | Whether to display commenter full name in issues and PRs | No | |
-| `organization-role-team-assignments-file` | Path to a YAML file defining organization role team assignments | No | |
-| `rulesets-file` | Comma-separated paths to JSON files, each with a single org ruleset config | No | |
-| `delete-unmanaged-rulesets` | Delete all other rulesets besides those being synced | No | `false` |
-| `custom-org-roles-file` | Path to a YAML file defining custom organization role definitions (GHEC only) | No | |
-| `delete-unmanaged-org-roles` | Delete custom org roles not defined in the configuration file | No | `false` |
-| `custom-repo-roles-file` | Path to a YAML file defining custom repository role definitions (GHEC only) | No | |
-| `delete-unmanaged-repo-roles` | Delete custom repo roles not defined in the configuration file | No | `false` |
-| `dot-github-source-dir` | Path to a local directory to sync to the `.github` repo in each org (via PR) | No | |
-| `dot-github-private-source-dir` | Path to a local directory to sync to the `.github-private` repo in each org (via PR) | No | |
-| `actions-policy-allowed-actions` | Allowed GitHub Actions policy: `all`, `local_only`, or `selected` | No | |
-| `actions-policy-github-owned-allowed` | Whether GitHub-owned actions are allowed (when `allowed-actions` is `selected`) | No | |
-| `actions-policy-verified-allowed` | Whether verified creator actions are allowed (when `allowed-actions` is `selected`) | No | |
-| `actions-allow-list-file` | Path to YAML file with allowed action/reusable workflow patterns | No | |
-| `actions-policy-default-workflow-permissions` | Default `GITHUB_TOKEN` permissions for workflows: `read` or `write` | No | |
-| `actions-policy-actions-can-approve-pull-request-reviews` | Whether GitHub Actions can approve pull request reviews | No | |
-| `org-name` | Organization display name | No | |
-| `org-description` | Organization description (max 160 chars) | No | |
-| `org-company` | Company name | No | |
-| `org-location` | Location | No | |
-| `org-email` | Publicly visible email | No | |
-| `org-twitter-username` | Twitter/X username | No | |
-| `org-url` | Website URL | No | |
-| `org-blog` | Blog/website URL (deprecated; use `org-url`) | No | |
-| `code-security-configurations-file` | Path to a YAML file defining code security configurations to sync | No | |
-| `delete-unmanaged-code-security-configurations` | Delete code security configurations not defined in the configuration file | No | `false` |
-| `dry-run` | Preview changes without applying them | No | `false` |
+| Input | Description | Required | Default |
+| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- | ----------------------- |
+| `github-token` | GitHub token for API access (requires `admin:org` scope) | Yes | |
+| `github-api-url` | GitHub API URL (e.g., `https://api.github.com` or `https://ghes.domain.com/api/v3`) | No | `${{ github.api_url }}` |
+| `organizations` | Comma-separated list of organization names | No | |
+| `organizations-file` | Path to YAML file containing organization settings configuration | No | |
+| `custom-properties-file` | Path to a YAML file defining custom property schemas | No | |
+| `delete-unmanaged-properties` | Delete custom properties not defined in the configuration file | No | `false` |
+| `issue-types-file` | Path to a YAML file defining issue type definitions | No | |
+| `delete-unmanaged-issue-types` | Delete issue types not defined in the configuration file | No | `false` |
+| `default-repository-permission` | Default permission for org members: `read`, `write`, `admin`, `none` | No | |
+| `members-can-create-repositories` | Whether members can create repositories | No | |
+| `members-can-create-public-repositories` | Whether members can create public repositories | No | |
+| `members-can-create-private-repositories` | Whether members can create private repositories | No | |
+| `members-can-create-internal-repositories` | Whether members can create internal repositories (GHEC/GHES only) | No | |
+| `members-can-fork-private-repositories` | Whether members can fork private repositories | No | |
+| `web-commit-signoff-required` | Whether web UI commits require signoff | No | |
+| `members-can-create-pages` | Whether members can create GitHub Pages sites | No | |
+| `members-can-create-public-pages` | Whether members can create public GitHub Pages sites | No | |
+| `members-can-create-private-pages` | Whether members can create private GitHub Pages sites | No | |
+| `members-can-invite-outside-collaborators` | Whether members can invite outside collaborators | No | |
+| `members-can-create-teams` | Whether members can create teams | No | |
+| `members-can-delete-repositories` | Whether members can delete repositories | No | |
+| `members-can-change-repo-visibility` | Whether members can change repository visibility | No | |
+| `members-can-delete-issues` | Whether members can delete issues | No | |
+| `default-repository-branch` | Default branch name for new repositories | No | |
+| `deploy-keys-enabled-for-repositories` | Whether deploy keys can be added to repositories | No | |
+| `readers-can-create-discussions` | Whether users with read access can create discussions | No | |
+| `members-can-view-dependency-insights` | Whether members can view dependency insights | No | |
+| `display-commenter-full-name-setting-enabled` | Whether to display commenter full name in issues and PRs | No | |
+| `organization-role-team-assignments-file` | Path to a YAML file defining organization role team assignments | No | |
+| `rulesets-file` | Comma-separated paths to JSON files, each with a single org ruleset config | No | |
+| `delete-unmanaged-rulesets` | Delete all other rulesets besides those being synced | No | `false` |
+| `custom-org-roles-file` | Path to a YAML file defining custom organization role definitions (GHEC only) | No | |
+| `delete-unmanaged-org-roles` | Delete custom org roles not defined in the configuration file | No | `false` |
+| `custom-repo-roles-file` | Path to a YAML file defining custom repository role definitions (GHEC only) | No | |
+| `delete-unmanaged-repo-roles` | Delete custom repo roles not defined in the configuration file | No | `false` |
+| `dot-github-source-dir` | Path to a local directory to sync to the `.github` repo in each org (via PR) | No | |
+| `dot-github-private-source-dir` | Path to a local directory to sync to the `.github-private` repo in each org (via PR) | No | |
+| `create-missing-dot-github-repos` | Create missing `.github` / `.github-private` repos before syncing (requires `administration: write`) | No | `false` |
+| `dot-github-repo-visibility` | Visibility for newly created `.github` repo: `public`, `private`, or `internal` | No | `public` |
+| `dot-github-private-repo-visibility` | Visibility for newly created `.github-private` repo: `public`, `private`, or `internal` | No | `private` |
+| `actions-policy-allowed-actions` | Allowed GitHub Actions policy: `all`, `local_only`, or `selected` | No | |
+| `actions-policy-github-owned-allowed` | Whether GitHub-owned actions are allowed (when `allowed-actions` is `selected`) | No | |
+| `actions-policy-verified-allowed` | Whether verified creator actions are allowed (when `allowed-actions` is `selected`) | No | |
+| `actions-allow-list-file` | Path to YAML file with allowed action/reusable workflow patterns | No | |
+| `actions-policy-default-workflow-permissions` | Default `GITHUB_TOKEN` permissions for workflows: `read` or `write` | No | |
+| `actions-policy-actions-can-approve-pull-request-reviews` | Whether GitHub Actions can approve pull request reviews | No | |
+| `org-name` | Organization display name | No | |
+| `org-description` | Organization description (max 160 chars) | No | |
+| `org-company` | Company name | No | |
+| `org-location` | Location | No | |
+| `org-email` | Publicly visible email | No | |
+| `org-twitter-username` | Twitter/X username | No | |
+| `org-url` | Website URL | No | |
+| `org-blog` | Blog/website URL (deprecated; use `org-url`) | No | |
+| `code-security-configurations-file` | Path to a YAML file defining code security configurations to sync | No | |
+| `delete-unmanaged-code-security-configurations` | Delete code security configurations not defined in the configuration file | No | `false` |
+| `dry-run` | Preview changes without applying them | No | `false` |
> [!NOTE]
> You must provide either `organizations` or `organizations-file`. The `custom-properties-file`, `issue-types-file`, `rulesets-file`, `custom-org-roles-file`, `custom-repo-roles-file`, `actions-allow-list-file`, `code-security-configurations-file`, and `organization-role-team-assignments-file` inputs provide base settings for all orgs and can be combined with either approach. Member privilege settings can be provided as individual inputs (e.g., `default-repository-permission`). Actions policy settings can be provided as individual inputs (e.g., `actions-policy-allowed-actions`). Org profile settings can be provided as individual inputs (e.g., `org-name`). The `dot-github-source-dir` and `dot-github-private-source-dir` inputs sync a local directory to the respective special repositories via PR. Per-org overrides in `organizations-file` layer on top of the base unless otherwise noted — see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for which features support inline vs file-path overrides and their precedence and merge semantics.
diff --git a/__tests__/index.test.js b/__tests__/index.test.js
index 98d42f2..7cb3f44 100644
--- a/__tests__/index.test.js
+++ b/__tests__/index.test.js
@@ -4173,6 +4173,226 @@ orgs:
expect(result.subResults).toHaveLength(1);
expect(result.subResults[0].kind).toBe('dot-github-private-sync');
});
+
+ test('should NOT create repo when createIfMissing is not opted in (default)', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ mockRequest.mockRejectedValueOnce(notFoundError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', false);
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('dot-github-sync');
+ expect(result.subResults[0].status).toBe('warning');
+ // No POST call should have been made
+ expect(mockRequest).toHaveBeenCalledTimes(1);
+ });
+
+ test('should still warn on 403 even when createIfMissing is true (not a 404)', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const forbiddenError = new Error('Forbidden');
+ forbiddenError.status = 403;
+ mockRequest.mockRejectedValueOnce(forbiddenError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', false, {
+ createIfMissing: true,
+ visibility: 'public'
+ });
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('dot-github-sync');
+ expect(result.subResults[0].status).toBe('warning');
+ expect(result.subResults[0].message).toContain('not found or token lacks access');
+ // No create call attempted
+ expect(mockRequest).toHaveBeenCalledTimes(1);
+ });
+
+ test('should create missing repo on 404 + opt-in and continue sync', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+
+ mockRequest
+ // GET /repos -> 404
+ .mockRejectedValueOnce(notFoundError)
+ // POST /orgs/{org}/repos -> created
+ .mockResolvedValueOnce({ data: { default_branch: 'main' } })
+ // GET ref
+ .mockResolvedValueOnce({ data: { object: { sha: 'base-sha' } } })
+ // GET commit
+ .mockResolvedValueOnce({ data: { tree: { sha: 'base-tree-sha' } } })
+ // GET tree (empty)
+ .mockResolvedValueOnce({ data: { tree: [] } })
+ // POST blob
+ .mockResolvedValueOnce({ data: { sha: 'new-blob-sha' } })
+ // POST tree
+ .mockResolvedValueOnce({ data: { sha: 'new-tree-sha' } })
+ // POST commit
+ .mockResolvedValueOnce({ data: { sha: 'new-commit-sha' } })
+ // POST ref
+ .mockResolvedValueOnce({ data: {} })
+ // GET pulls
+ .mockResolvedValueOnce({ data: [] })
+ // POST PR
+ .mockResolvedValueOnce({ data: { number: 7, html_url: 'https://github.com/my-org/.github/pull/7' } });
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', false, {
+ createIfMissing: true,
+ visibility: 'public'
+ });
+
+ expect(mockRequest).toHaveBeenCalledWith('POST /orgs/{org}/repos', {
+ org: 'my-org',
+ name: '.github',
+ visibility: 'public',
+ auto_init: true
+ });
+
+ const kinds = result.subResults.map(r => r.kind);
+ expect(kinds).toContain('dot-github-create');
+ expect(kinds).toContain('dot-github-sync');
+ const createResult = result.subResults.find(r => r.kind === 'dot-github-create');
+ expect(createResult.status).toBe('changed');
+ expect(createResult.message).toContain('Created repo my-org/.github');
+ expect(result.failed).toBe(false);
+ });
+
+ test('should emit friendly EMU error on 422 when creating with public visibility', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ const validationError = new Error(`Visibility can't be public`);
+ validationError.status = 422;
+
+ mockRequest.mockRejectedValueOnce(notFoundError).mockRejectedValueOnce(validationError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', false, {
+ createIfMissing: true,
+ visibility: 'public'
+ });
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('dot-github-create');
+ expect(result.subResults[0].status).toBe('warning');
+ expect(result.subResults[0].message).toContain('does not allow public repositories');
+ expect(result.subResults[0].message).toContain('dot-github-repo-visibility: internal');
+ expect(result.failed).toBe(true);
+ });
+
+ test('should log "Would create" and skip sync in dry-run when repo missing + opt-in', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ mockRequest.mockRejectedValueOnce(notFoundError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', true, {
+ createIfMissing: true,
+ visibility: 'internal'
+ });
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('dot-github-create');
+ expect(result.subResults[0].status).toBe('changed');
+ expect(result.subResults[0].message).toContain('Would create repo my-org/.github');
+ expect(result.subResults[0].message).toContain('visibility: internal');
+ // Only the failed GET; no POST create call in dry-run
+ expect(mockRequest).toHaveBeenCalledTimes(1);
+ expect(result.failed).toBe(false);
+ });
+
+ test('should use dot-github-private-create kind when creating .github-private', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.md', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.md') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ mockRequest.mockRejectedValueOnce(notFoundError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github-private', true, {
+ createIfMissing: true,
+ visibility: 'private'
+ });
+
+ expect(result.subResults[0].kind).toBe('dot-github-private-create');
+ expect(result.subResults[0].message).toContain('visibility: private');
+ });
+
+ test('should fall back to repo-specific default visibility when visibility option is empty', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.md', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.md') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ mockRequest.mockRejectedValueOnce(notFoundError);
+
+ const result = await syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github-private', true, {
+ createIfMissing: true,
+ visibility: ''
+ });
+
+ expect(result.subResults[0].kind).toBe('dot-github-private-create');
+ expect(result.subResults[0].message).toContain('visibility: private');
+ expect(result.subResults[0].message).not.toContain('undefined');
+ });
+
+ test('should reject invalid visibility value when creating', async () => {
+ mockFs.readdirSync.mockReturnValue([{ name: 'file.txt', isDirectory: () => false, isFile: () => true }]);
+ mockFs.readFileSync.mockImplementation((filePath, _encoding) => {
+ if (filePath === '/source/file.txt') return Buffer.from('content');
+ if (typeof filePath === 'string' && filePath.endsWith('action.yml')) return mockActionYmlContent;
+ throw new Error(`ENOENT: ${filePath}`);
+ });
+
+ const notFoundError = new Error('Not Found');
+ notFoundError.status = 404;
+ mockRequest.mockRejectedValueOnce(notFoundError);
+
+ await expect(
+ syncDotGithubRepo(mockOctokit, 'my-org', '/source', '.github', false, {
+ createIfMissing: true,
+ visibility: 'secret'
+ })
+ ).rejects.toThrow(/Invalid "dot-github-repo-visibility"/);
+ });
});
// ─── parseOrganizations with dot-github inputs ────────────────────────
@@ -4267,6 +4487,103 @@ orgs:
'Invalid "dot-github-private-source-dir" for org "org1": expected a non-empty string'
);
});
+
+ test('should propagate createMissingDotGithubRepos and visibility from base inputs', () => {
+ const result = parseOrganizations(
+ 'org1,org2',
+ '',
+ '',
+ [],
+ false,
+ '',
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ '/source',
+ '/private-source',
+ null,
+ true,
+ 'internal',
+ 'private'
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].createMissingDotGithubRepos).toBe(true);
+ expect(result[0].dotGithubRepoVisibility).toBe('internal');
+ expect(result[0].dotGithubPrivateRepoVisibility).toBe('private');
+ expect(result[1].createMissingDotGithubRepos).toBe(true);
+ expect(result[1].dotGithubRepoVisibility).toBe('internal');
+ });
+
+ test('should allow per-org override of dot-github visibility in orgs file', () => {
+ setMockFileContent(
+ `
+orgs:
+ - org: org1
+ dot-github-repo-visibility: internal
+ - org: org2
+`,
+ '/mock/orgs.yml'
+ );
+
+ const result = parseOrganizations(
+ '',
+ '/mock/orgs.yml',
+ '',
+ [],
+ false,
+ '',
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ '/source',
+ '',
+ null,
+ true,
+ 'public',
+ 'private'
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].dotGithubRepoVisibility).toBe('internal');
+ expect(result[1].dotGithubRepoVisibility).toBe('public');
+ });
+
+ test('should reject invalid per-org dot-github-repo-visibility', () => {
+ setMockFileContent(
+ `
+orgs:
+ - org: org1
+ dot-github-repo-visibility: secret
+`,
+ '/mock/orgs.yml'
+ );
+
+ expect(() => parseOrganizations('', '/mock/orgs.yml')).toThrow(/Invalid "dot-github-repo-visibility".*org1/);
+ });
+
+ test('should reject non-boolean per-org create-missing-dot-github-repos', () => {
+ setMockFileContent(
+ `
+orgs:
+ - org: org1
+ create-missing-dot-github-repos: 'yes'
+`,
+ '/mock/orgs.yml'
+ );
+
+ expect(() => parseOrganizations('', '/mock/orgs.yml')).toThrow(
+ 'Invalid "create-missing-dot-github-repos" for org "org1": expected a boolean'
+ );
+ });
});
// ─── Custom Organization Roles ──────────────────────────────────────
diff --git a/action.yml b/action.yml
index 2921533..01eca02 100644
--- a/action.yml
+++ b/action.yml
@@ -157,6 +157,18 @@ inputs:
dot-github-private-source-dir:
description: 'Path to a local directory whose contents should be synced to the .github-private repository in each target organization (creates a PR with changes)'
required: false
+ create-missing-dot-github-repos:
+ description: 'Whether to create missing .github / .github-private repositories before syncing (only applies to repos with a configured source-dir). Requires administration: write on the GitHub App at the org level.'
+ required: false
+ default: 'false'
+ dot-github-repo-visibility:
+ description: 'Visibility to use when creating the .github repository: public, private, or internal. EMU and restricted-GHEC orgs should set this to internal.'
+ required: false
+ default: 'public'
+ dot-github-private-repo-visibility:
+ description: 'Visibility to use when creating the .github-private repository: public, private, or internal.'
+ required: false
+ default: 'private'
# === Rulesets ===
rulesets-file:
diff --git a/badges/coverage.svg b/badges/coverage.svg
index c7f5cc5..b693877 100644
--- a/badges/coverage.svg
+++ b/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 7065f6c..f9c154b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulk-github-org-settings-sync-action",
- "version": "1.10.1",
+ "version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulk-github-org-settings-sync-action",
- "version": "1.10.1",
+ "version": "1.11.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.1",
diff --git a/package.json b/package.json
index fcdad7e..b30457c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "bulk-github-org-settings-sync-action",
"description": "🏢 Bulk configure GitHub organization settings across multiple orgs using a declarative YAML config",
- "version": "1.10.1",
+ "version": "1.11.0",
"type": "module",
"author": {
"name": "Josh Johanning",
diff --git a/src/index.js b/src/index.js
index 3cd8ff2..375107d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -269,6 +269,9 @@ const ORG_CONFIG_TOP_LEVEL_KEYS = new Set([
'actions-allow-list-file',
'dot-github-source-dir',
'dot-github-private-source-dir',
+ 'create-missing-dot-github-repos',
+ 'dot-github-repo-visibility',
+ 'dot-github-private-repo-visibility',
...ORG_PROFILE_SETTINGS.keys()
]);
@@ -550,7 +553,9 @@ const SYNC_KIND_LABELS = Object.freeze({
'actions-policy-selected-actions-update': 'actions policy (selected actions updated)',
'actions-policy-allow-list-update': 'actions policy (allow list updated)',
'dot-github-sync': '.github repo (synced)',
- 'dot-github-private-sync': '.github-private repo (synced)'
+ 'dot-github-private-sync': '.github-private repo (synced)',
+ 'dot-github-create': '.github repo (created)',
+ 'dot-github-private-create': '.github-private repo (created)'
});
/**
@@ -573,6 +578,38 @@ function isPermissionLikeFetchError(error) {
return error.status === 403 || error.status === 404;
}
+/**
+ * Allowed visibility values when creating a .github / .github-private repository.
+ */
+const DOT_GITHUB_VISIBILITIES = Object.freeze(['public', 'private', 'internal']);
+
+/**
+ * Validate and normalize a dot-github visibility value.
+ * @param {*} value - Raw value (from action input or YAML)
+ * @param {string} fieldName - Field/input name for error messages
+ * @param {string} [context] - Optional context (e.g., org name) for error messages
+ * @returns {string|undefined} Normalized visibility ('public' | 'private' | 'internal'), or undefined for empty values
+ */
+function validateDotGithubVisibility(value, fieldName, context) {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+ if (typeof value !== 'string') {
+ const where = context ? ` for org "${context}"` : '';
+ throw new Error(
+ `Invalid "${fieldName}"${where}: expected one of ${DOT_GITHUB_VISIBILITIES.join(', ')}, got ${typeof value}`
+ );
+ }
+ const normalized = value.trim().toLowerCase();
+ if (!DOT_GITHUB_VISIBILITIES.includes(normalized)) {
+ const where = context ? ` for org "${context}"` : '';
+ throw new Error(
+ `Invalid "${fieldName}"${where}: expected one of ${DOT_GITHUB_VISIBILITIES.join(', ')}, got "${value}"`
+ );
+ }
+ return normalized;
+}
+
/**
* Format a fetch warning that points users at likely GitHub App permission fixes.
* @param {string} resource - Resource being fetched
@@ -696,7 +733,10 @@ function formatSubResultStatus(status) {
* @param {string} [dotGithubSourceDir] - Path to source directory for .github repo sync
* @param {string} [dotGithubPrivateSourceDir] - Path to source directory for .github-private repo sync
* @param {string} [organizationRoleTeamAssignmentsFile] - Path to organization role team assignments YAML file (base for all orgs)
- * @returns {Array<{ org: string, customProperties?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string }>} Parsed org configs
+ * @param {boolean} [createMissingDotGithubRepos] - Whether to create missing .github / .github-private repos before syncing
+ * @param {string} [dotGithubRepoVisibility] - Visibility used when creating the .github repo
+ * @param {string} [dotGithubPrivateRepoVisibility] - Visibility used when creating the .github-private repo
+ * @returns {Array<{ org: string, customProperties?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string, createMissingDotGithubRepos?: boolean, dotGithubRepoVisibility?: string, dotGithubPrivateRepoVisibility?: string }>} Parsed org configs
*/
export function parseOrganizations(
organizationsInput,
@@ -714,7 +754,10 @@ export function parseOrganizations(
actionsAllowListFile,
dotGithubSourceDir,
dotGithubPrivateSourceDir,
- organizationRoleTeamAssignmentsFile
+ organizationRoleTeamAssignmentsFile,
+ createMissingDotGithubRepos,
+ dotGithubRepoVisibility,
+ dotGithubPrivateRepoVisibility
) {
let resolvedCodeSecurityConfigurationsFile = codeSecurityConfigurationsFile;
let resolvedActionsPolicyFromInputs = actionsPolicyFromInputs;
@@ -980,6 +1023,21 @@ export function parseOrganizations(
if (!orgConfig.dotGithubPrivateSourceDir && dotGithubPrivateSourceDir) {
orgConfig.dotGithubPrivateSourceDir = dotGithubPrivateSourceDir;
}
+
+ // Per-org createMissingDotGithubRepos (falls back to base input)
+ if (orgConfig.createMissingDotGithubRepos === undefined && createMissingDotGithubRepos !== undefined) {
+ orgConfig.createMissingDotGithubRepos = createMissingDotGithubRepos;
+ }
+
+ // Per-org dot-github-repo-visibility (falls back to base input)
+ if (!orgConfig.dotGithubRepoVisibility && dotGithubRepoVisibility) {
+ orgConfig.dotGithubRepoVisibility = dotGithubRepoVisibility;
+ }
+
+ // Per-org dot-github-private-repo-visibility (falls back to base input)
+ if (!orgConfig.dotGithubPrivateRepoVisibility && dotGithubPrivateRepoVisibility) {
+ orgConfig.dotGithubPrivateRepoVisibility = dotGithubPrivateRepoVisibility;
+ }
}
return orgConfigs;
@@ -1020,7 +1078,10 @@ export function parseOrganizations(
...(baseActionsPolicy ? { actionsPolicy: baseActionsPolicy } : {}),
...(baseActionsAllowList ? { actionsAllowList: baseActionsAllowList } : {}),
...(dotGithubSourceDir ? { dotGithubSourceDir } : {}),
- ...(dotGithubPrivateSourceDir ? { dotGithubPrivateSourceDir } : {})
+ ...(dotGithubPrivateSourceDir ? { dotGithubPrivateSourceDir } : {}),
+ ...(createMissingDotGithubRepos !== undefined ? { createMissingDotGithubRepos } : {}),
+ ...(dotGithubRepoVisibility ? { dotGithubRepoVisibility } : {}),
+ ...(dotGithubPrivateRepoVisibility ? { dotGithubPrivateRepoVisibility } : {})
}));
}
@@ -1782,6 +1843,30 @@ export function parseOrganizationsFile(filePath) {
result.dotGithubPrivateSourceDir = val.trim();
}
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'create-missing-dot-github-repos')) {
+ const val = orgConfig['create-missing-dot-github-repos'];
+ if (typeof val !== 'boolean') {
+ throw new Error(`Invalid "create-missing-dot-github-repos" for org "${orgConfig.org}": expected a boolean`);
+ }
+ result.createMissingDotGithubRepos = val;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'dot-github-repo-visibility')) {
+ result.dotGithubRepoVisibility = validateDotGithubVisibility(
+ orgConfig['dot-github-repo-visibility'],
+ 'dot-github-repo-visibility',
+ orgConfig.org
+ );
+ }
+
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'dot-github-private-repo-visibility')) {
+ result.dotGithubPrivateRepoVisibility = validateDotGithubVisibility(
+ orgConfig['dot-github-private-repo-visibility'],
+ 'dot-github-private-repo-visibility',
+ orgConfig.org
+ );
+ }
+
return result;
});
}
@@ -3954,13 +4039,18 @@ function formatChangedFilesSummary(changedFiles, limit = 10) {
* @param {string} sourceDir - Path to the local source directory
* @param {string} repoName - Target repo name ('.github' or '.github-private')
* @param {boolean} dryRun - Preview mode
+ * @param {Object} [options] - Optional creation behavior
+ * @param {boolean} [options.createIfMissing=false] - Create the repo on a true 404 before syncing
+ * @param {string} [options.visibility='public'] - Visibility used when creating the repo ('public' | 'private' | 'internal')
* @returns {Promise