From 8ce0c4cd5174541cf59c20138dc6e267e5dd62d5 Mon Sep 17 00:00:00 2001 From: Adam Letizia Date: Fri, 27 Sep 2024 15:32:35 -0500 Subject: [PATCH] feat(scaffolder-backend-module-github): add github:branch-protection:create action Signed-off-by: Adam Letizia --- .changeset/chilled-melons-smash.md | 20 ++ .../report.api.md | 35 +++ .../githubBranchProtection.examples.test.ts | 174 +++++++++++++++ .../githubBranchProtection.examples.ts | 70 ++++++ .../actions/githubBranchProtection.test.ts | 211 ++++++++++++++++++ .../src/actions/githubBranchProtection.ts | 153 +++++++++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 4 + 8 files changed, 668 insertions(+) create mode 100644 .changeset/chilled-melons-smash.md create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts create mode 100644 plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts diff --git a/.changeset/chilled-melons-smash.md b/.changeset/chilled-melons-smash.md new file mode 100644 index 0000000000..ca8f02ff37 --- /dev/null +++ b/.changeset/chilled-melons-smash.md @@ -0,0 +1,20 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Add `github:branch-protection:create` scaffolder action to set branch protection on an existing repository. Example usage: + +```yaml +- id: set-branch-protection + name: Set Branch Protection + action: github:branch-protection:create + input: + repoUrl: 'github.com?repo=backstage&owner=backstage' + branch: master + enforceAdmins: true # default + requiredApprovingReviewCount: 1 # default + requireBranchesToBeUpToDate: true # default + requireCodeOwnerReviews: true + dismissStaleReviews: true + requiredConversationResolution: true +``` diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 86964b7623..547c61768f 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -49,6 +49,41 @@ export function createGithubAutolinksAction(options: { JsonObject >; +// @public +export function createGithubBranchProtectionAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction< + { + repoUrl: string; + branch?: string | undefined; + enforceAdmins?: boolean | undefined; + requiredApprovingReviewCount?: number | undefined; + requireCodeOwnerReviews?: boolean | undefined; + dismissStaleReviews?: boolean | undefined; + bypassPullRequestAllowances?: + | { + users?: string[]; + teams?: string[]; + apps?: string[]; + } + | undefined; + restrictions?: + | { + users: string[]; + teams: string[]; + apps?: string[]; + } + | undefined; + requiredStatusCheckContexts?: string[] | undefined; + requireBranchesToBeUpToDate?: boolean | undefined; + requiredConversationResolution?: boolean | undefined; + requireLastPushApproval?: boolean | undefined; + requiredCommitSigning?: boolean | undefined; + token?: string | undefined; + }, + JsonObject +>; + // @public export function createGithubDeployKeyAction(options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts new file mode 100644 index 0000000000..5443d7304c --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import yaml from 'yaml'; +import { examples } from './githubBranchProtection.examples'; +import { createGithubBranchProtectionAction } from './githubBranchProtection'; + +const mockOctokit = { + rest: { + repos: { + createCommitSignatureProtection: jest.fn(), + get: jest.fn(), + updateBranchProtection: jest.fn(), + }, + }, +}; +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('github:branch-protection:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + let action: TemplateAction; + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'master', + }, + }); + + action = createGithubBranchProtectionAction({ + integrations, + }); + }); + + it('should create branch protection for the default branch with default params', async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should create branch protection for the specified branch', async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'my-awesome-branch', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should create branch protection with params and require commit signing', async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repo', + branch: 'master', + required_status_checks: { + strict: true, + contexts: ['test'], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: true, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_conversation_resolution: true, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + branch: 'master', + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts new file mode 100644 index 0000000000..4d0eb253ef --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.examples.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: `GitHub Branch Protection for repository's default branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }, + ], + }), + }, + { + description: `GitHub Branch Protection for a specific branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + branch: 'my-awesome-branch', + }, + }, + ], + }), + }, + { + description: `GitHub Branch Protection and required commit signing on default branch.`, + example: yaml.stringify({ + steps: [ + { + action: 'github:branch-protection:create', + name: 'Setup Branch Protection', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + requireCodeOwnerReviews: true, + requiredStatusCheckContexts: ['test'], + dismissStaleReviews: true, + requireLastPushApproval: true, + requiredConversationResolution: true, + requiredCommitSigning: true, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts new file mode 100644 index 0000000000..d4eac30b81 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createGithubBranchProtectionAction } from './githubBranchProtection'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; + +const mockOctokit = { + rest: { + repos: { + createCommitSignatureProtection: jest.fn(), + get: jest.fn(), + updateBranchProtection: jest.fn(), + }, + }, +}; + +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('github:branch-protection:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + let action: TemplateAction; + + const mockContext = createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repository&owner=owner', + name: 'envname', + }, + }); + + beforeEach(() => { + mockOctokit.rest.repos.get.mockResolvedValue({ + data: { + default_branch: 'master', + }, + }); + + action = createGithubBranchProtectionAction({ + integrations, + }); + }); + + afterEach(jest.resetAllMocks); + + it('should work with default params', async () => { + await action.handler(mockContext); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).not.toHaveBeenCalled(); + }); + + it('should require commit signing on default branch', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + requiredCommitSigning: true, + }, + }); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'master', + required_status_checks: { + strict: true, + contexts: [], + }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { + required_approving_review_count: 1, + require_code_owner_reviews: false, + bypass_pull_request_allowances: undefined, + dismiss_stale_reviews: false, + require_last_push_approval: false, + }, + required_conversation_resolution: false, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repository', + branch: 'master', + }); + }); + + it('should work with all params supplied', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + branch: 'branch', + enforceAdmins: false, + requiredApprovingReviewCount: 2, + requireCodeOwnerReviews: true, + dismissStaleReviews: true, + bypassPullRequestAllowances: { + users: ['user1'], + teams: ['team1'], + apps: ['app1'], + }, + restrictions: { + users: ['user2'], + teams: ['team2'], + apps: ['app2'], + }, + requiredStatusCheckContexts: ['context1', 'context2'], + requireBranchesToBeUpToDate: false, + requiredConversationResolution: true, + requireLastPushApproval: true, + requiredCommitSigning: true, + }, + }); + + expect(mockOctokit.rest.repos.updateBranchProtection).toHaveBeenCalledWith({ + mediaType: { + previews: ['luke-cage-preview'], + }, + owner: 'owner', + repo: 'repository', + branch: 'branch', + required_status_checks: { + strict: false, + contexts: ['context1', 'context2'], + }, + restrictions: { + users: ['user2'], + teams: ['team2'], + apps: ['app2'], + }, + enforce_admins: false, + required_pull_request_reviews: { + required_approving_review_count: 2, + require_code_owner_reviews: true, + bypass_pull_request_allowances: { + users: ['user1'], + teams: ['team1'], + apps: ['app1'], + }, + dismiss_stale_reviews: true, + require_last_push_approval: true, + }, + required_conversation_resolution: true, + }); + expect( + mockOctokit.rest.repos.createCommitSignatureProtection, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repository', + branch: 'branch', + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts new file mode 100644 index 0000000000..4ab847c98e --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/actions/githubBranchProtection.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createTemplateAction, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { examples } from './githubBranchProtection.examples'; +import * as inputProps from './inputProperties'; +import { getOctokitOptions } from './helpers'; +import { Octokit } from 'octokit'; +import { enableBranchProtectionOnDefaultRepoBranch } from './gitHelpers'; + +/** + * Creates an `github:branch-protection:create` Scaffolder action that configured Branch Protection in a Github Repository. + * + * @public + */ +export function createGithubBranchProtectionAction(options: { + integrations: ScmIntegrationRegistry; +}) { + const { integrations } = options; + + return createTemplateAction<{ + repoUrl: string; + branch?: string; + enforceAdmins?: boolean; + requiredApprovingReviewCount?: number; + requireCodeOwnerReviews?: boolean; + dismissStaleReviews?: boolean; + bypassPullRequestAllowances?: + | { + users?: string[]; + teams?: string[]; + apps?: string[]; + } + | undefined; + restrictions?: + | { + users: string[]; + teams: string[]; + apps?: string[]; + } + | undefined; + requiredStatusCheckContexts?: string[]; + requireBranchesToBeUpToDate?: boolean; + requiredConversationResolution?: boolean; + requireLastPushApproval?: boolean; + requiredCommitSigning?: boolean; + token?: string; + }>({ + id: 'github:branch-protection:create', + description: 'Configures Branch Protection', + examples, + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: inputProps.repoUrl, + branch: { + title: 'Branch name', + description: `The branch to protect. Defaults to the repository's default branch`, + type: 'string', + }, + enforceAdmins: inputProps.protectEnforceAdmins, + requiredApprovingReviewCount: inputProps.requiredApprovingReviewCount, + requireCodeOwnerReviews: inputProps.requireCodeOwnerReviews, + dismissStaleReviews: inputProps.dismissStaleReviews, + bypassPullRequestAllowances: inputProps.bypassPullRequestAllowances, + restrictions: inputProps.restrictions, + requiredStatusCheckContexts: inputProps.requiredStatusCheckContexts, + requireBranchesToBeUpToDate: inputProps.requireBranchesToBeUpToDate, + requiredConversationResolution: + inputProps.requiredConversationResolution, + requireLastPushApproval: inputProps.requireLastPushApproval, + requiredCommitSigning: inputProps.requiredCommitSigning, + token: inputProps.token, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + branch, + enforceAdmins = true, + requiredApprovingReviewCount = 1, + requireCodeOwnerReviews = false, + dismissStaleReviews = false, + bypassPullRequestAllowances, + restrictions, + requiredStatusCheckContexts = [], + requireBranchesToBeUpToDate = true, + requiredConversationResolution = false, + requireLastPushApproval = false, + requiredCommitSigning = false, + token: providedToken, + } = ctx.input; + + const octokitOptions = await getOctokitOptions({ + integrations, + token: providedToken, + repoUrl: repoUrl, + }); + const client = new Octokit(octokitOptions); + + const { owner, repo } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError(`No owner provided for repo ${repoUrl}`); + } + + const repository = await client.rest.repos.get({ + owner: owner, + repo: repo, + }); + + await enableBranchProtectionOnDefaultRepoBranch({ + repoName: repo, + client, + owner, + logger: ctx.logger, + requireCodeOwnerReviews, + bypassPullRequestAllowances, + requiredApprovingReviewCount, + restrictions, + requiredStatusCheckContexts, + requireBranchesToBeUpToDate, + requiredConversationResolution, + requireLastPushApproval, + defaultBranch: branch ?? repository.data.default_branch, + enforceAdmins, + dismissStaleReviews, + requiredCommitSigning, + }); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-github/src/actions/index.ts b/plugins/scaffolder-backend-module-github/src/actions/index.ts index 6f37678099..c758415e1d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/index.ts @@ -28,5 +28,6 @@ export { export { createPublishGithubAction } from './github'; export { createGithubAutolinksAction } from './githubAutolinks'; export { createGithubPagesEnableAction } from './githubPagesEnable'; +export { createGithubBranchProtectionAction } from './githubBranchProtection'; export { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index 03835cf411..f837ad0456 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -30,6 +30,7 @@ import { createPublishGithubAction, createPublishGithubPullRequestAction, createGithubPagesEnableAction, + createGithubBranchProtectionAction, } from './actions'; import { DefaultGithubCredentialsProvider, @@ -102,6 +103,9 @@ export const githubModule = createBackendModule({ integrations, githubCredentialsProvider, }), + createGithubBranchProtectionAction({ + integrations, + }), ); }, });