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 @@ -Coverage: 86.89%Coverage86.89% \ No newline at end of file +Coverage: 86.86%Coverage86.86% \ 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} Result object with subResults */ -export async function syncDotGithubRepo(octokit, org, sourceDir, repoName, dryRun) { +export async function syncDotGithubRepo(octokit, org, sourceDir, repoName, dryRun, options = {}) { + const { createIfMissing = false, visibility = repoName === '.github-private' ? 'private' : 'public' } = options; const subResults = []; const wouldPrefix = dryRun ? 'Would ' : ''; let hasFailed = false; const kindLabel = repoName === '.github-private' ? 'dot-github-private-sync' : 'dot-github-sync'; + const createKindLabel = repoName === '.github-private' ? 'dot-github-private-create' : 'dot-github-create'; // List local files let localFiles; @@ -3986,15 +4076,59 @@ export async function syncDotGithubRepo(octokit, org, sourceDir, repoName, dryRu }); defaultBranch = repoData.default_branch; } catch (error) { - if (isPermissionLikeFetchError(error)) { + if (error.status === 404 && createIfMissing) { + const visibilityFieldName = + repoName === '.github-private' ? 'dot-github-private-repo-visibility' : 'dot-github-repo-visibility'; + const fallbackVisibility = repoName === '.github-private' ? 'private' : 'public'; + const normalizedVisibility = + validateDotGithubVisibility(visibility, visibilityFieldName, org) ?? fallbackVisibility; + + if (dryRun) { + const message = `Would create repo ${org}/${repoName} (visibility: ${normalizedVisibility})`; + core.info(` 🆕 ${message}`); + subResults.push(createSubResult(createKindLabel, SubResultStatus.CHANGED, message)); + return { subResults, failed: false }; + } + + core.info(` 🆕 Creating repo ${org}/${repoName} (visibility: ${normalizedVisibility})...`); + try { + const { data: createdRepo } = await octokit.request('POST /orgs/{org}/repos', { + org, + name: repoName, + visibility: normalizedVisibility, + auto_init: true + }); + defaultBranch = createdRepo.default_branch; + const message = `Created repo ${org}/${repoName} (visibility: ${normalizedVisibility})`; + core.info(` ✅ ${message}`); + subResults.push(createSubResult(createKindLabel, SubResultStatus.CHANGED, message)); + } catch (createError) { + if (createError.status === 422 && normalizedVisibility === 'public') { + const message = + `Failed to create ${org}/${repoName}: organization "${org}" does not allow public repositories ` + + `(likely Enterprise Managed Users or restricted GHEC). ` + + `Set "${visibilityFieldName}: internal" (or "private") to override.`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult(createKindLabel, SubResultStatus.WARNING, message)); + return { subResults, failed: true }; + } + const message = + `Failed to create repo ${org}/${repoName} (status ${createError.status ?? 'unknown'}): ${createError.message}. ` + + `If using a GitHub App, ensure it has "administration: write" at the org level.`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult(createKindLabel, SubResultStatus.WARNING, message)); + return { subResults, failed: true }; + } + } else if (isPermissionLikeFetchError(error)) { const message = `Repository "${org}/${repoName}" not found or token lacks access (status ${error.status}). ` + `If you expect this repository to exist and are using a GitHub App, verify it has the proper permissions and has been installed or re-approved if permissions were recently modified.`; core.warning(` ⚠️ ${message}`); subResults.push(createSubResult(kindLabel, SubResultStatus.WARNING, message)); return { subResults, failed: false }; + } else { + throw error; } - throw error; } // Fetch the default branch tree once to compare all local files without one Contents API call per file. @@ -5149,6 +5283,15 @@ export async function run() { const deleteUnmanagedIssueTypes = getBooleanInput('delete-unmanaged-issue-types') ?? false; const dotGithubSourceDir = core.getInput('dot-github-source-dir') || ''; const dotGithubPrivateSourceDir = core.getInput('dot-github-private-source-dir') || ''; + const createMissingDotGithubRepos = getBooleanInput('create-missing-dot-github-repos') ?? false; + const dotGithubRepoVisibility = validateDotGithubVisibility( + core.getInput('dot-github-repo-visibility') || 'public', + 'dot-github-repo-visibility' + ); + const dotGithubPrivateRepoVisibility = validateDotGithubVisibility( + core.getInput('dot-github-private-repo-visibility') || 'private', + 'dot-github-private-repo-visibility' + ); const memberPrivilegesFromInputs = getMemberPrivilegesFromInputs(); const organizationRoleTeamAssignmentsFile = core.getInput('organization-role-team-assignments-file'); const customOrgRolesFile = core.getInput('custom-org-roles-file'); @@ -5190,7 +5333,10 @@ export async function run() { actionsAllowListFile, dotGithubSourceDir, dotGithubPrivateSourceDir, - organizationRoleTeamAssignmentsFile + organizationRoleTeamAssignmentsFile, + createMissingDotGithubRepos, + dotGithubRepoVisibility, + dotGithubPrivateRepoVisibility ); // Check that at least one setting type is specified @@ -5380,7 +5526,10 @@ export async function run() { // Sync .github repo if (orgConfig.dotGithubSourceDir) { core.info(` 📁 Syncing .github repo from "${orgConfig.dotGithubSourceDir}"...`); - const dgResult = await syncDotGithubRepo(octokit, org, orgConfig.dotGithubSourceDir, '.github', dryRun); + const dgResult = await syncDotGithubRepo(octokit, org, orgConfig.dotGithubSourceDir, '.github', dryRun, { + createIfMissing: orgConfig.createMissingDotGithubRepos ?? createMissingDotGithubRepos, + visibility: orgConfig.dotGithubRepoVisibility ?? dotGithubRepoVisibility + }); result.subResults.push(...dgResult.subResults); if (dgResult.failed) { @@ -5397,7 +5546,11 @@ export async function run() { org, orgConfig.dotGithubPrivateSourceDir, '.github-private', - dryRun + dryRun, + { + createIfMissing: orgConfig.createMissingDotGithubRepos ?? createMissingDotGithubRepos, + visibility: orgConfig.dotGithubPrivateRepoVisibility ?? dotGithubPrivateRepoVisibility + } ); result.subResults.push(...dgpResult.subResults);