diff --git a/README.md b/README.md index cd2ef10e5..2260c4349 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Auto has an extensive plugin system and wide variety of official plugins. Make a - [slack](./plugins/slack) - Post release notes to slack - [twitter](./plugins/twitter) - Post release notes to twitter - [upload-assets](./plugins/upload-assets) - Add extra assets to the release +- [protected-branch](./plugins/protected-branch) - Handle Github branch protections and avoid run auto with an admin token ## :hammer: Start Developing :hammer: diff --git a/docs/pages/docs/_sidebar.mdx b/docs/pages/docs/_sidebar.mdx index 5e8285887..fe3127b86 100644 --- a/docs/pages/docs/_sidebar.mdx +++ b/docs/pages/docs/_sidebar.mdx @@ -72,6 +72,7 @@ Functionality Plugins - [Omit Commits](./generated/omit-commits) - [Omit Release Notes](./generated/omit-release-notes) - [PR Body Labels](./generated/pr-body-labels) +- [Protected Branch](./generated/protected-branch) - [Released](./generated/released) - [Slack](./generated/slack) - [Twitter](./generated/twitter) diff --git a/plugins/protected-branch/README.md b/plugins/protected-branch/README.md new file mode 100644 index 000000000..ec07b0e63 --- /dev/null +++ b/plugins/protected-branch/README.md @@ -0,0 +1,104 @@ +# Protected-Branch Plugin + +Handle Github branch protections and avoid run auto with an admin token + +## Prerequisites + +This plugin still needs `Personal Access token` (PAT), but only with for a standard user with `write` permission on your repository. + +That's means no need to have an Administration user. + +That's also means that you are able to enforce all branches protection requirements for Administrators of your Organization. + +When enforcing code owners, This user/ or a team must be designated as Owner/Co-Owner of released files. + +## Installation + +This plugin is not included with the `auto` CLI installed via NPM. To install: + +```bash +npm i --save-dev @auto-it/protected-branch +# or +yarn add -D @auto-it/protected-branch +``` + +## Usage + +No config example : + +```json +{ + "plugins": [ + "protected-branch" + // other plugins + ] +} +``` + +Fully configured example : + +```json +{ + "plugins": [ + [ + "protected-branch", + { + "reviewerToken": "redacted", // Probably better idea to set it in `PROTECTED_BRANCH_REVIEWER_TOKEN` environment variable + "releaseTemporaryBranchPrefix": "protected-release-", + "requiredStatusChecks": ["check-1", "check-2"] + } + ] + // other plugins + ] +} +``` + +## Configuration + +## How to handle branch protection + +The plugin intent to handled branches protections, without the need to use an administrators privileges or/and don't want to use administrator token in our workflow. + +An example usage in a repository where we want to have the following protected branch configuration : + +![branch-protection-part-1](doc/branch-protection-1.png) +![branch-protection-part-2](doc/branch-protection-2.png) + +1. Create a bot account in this org (`auto-release-bot@org.com`) +2. Create a PAT with this bot user and give a `repo` permissions +3. On the repository, create a github actions secrets with the previously created PAT +4. On the repository, add `write` access to the bot account +5. When using CodeOwners, on the repository, for each released asset, let the bot account be owner and/or co-owners of each asset + + ``` + # Automatically released files must be also owned by our automation @bots team + package.json @org/owner-team auto-release-bot@org.com + CHANGELOG.md @prg/owner-team auto-release-bot@org.com + ``` + +6. Configure this plugin correctly (see [Configuration](#configuration)) +7. On the repository, be sure add `PROTECTED_BRANCH_REVIEWER_TOKEN` environment variable, and included the relevant permissions + + ```yaml + permissions: + # Needed to create PR statuses/checks + checks: write + statuses: write + # Needed to push git tags, release + contents: write + ... + # On auto shipit job step + - name: Release + env: + PROTECTED_BRANCH_REVIEWER_TOKEN: ${{ secrets.<> }} + run: yarn shipit + ``` + +8. Ship it ! + +## Limitations + +This plugin is not yet ready to : + +- Handle more than 1 review requirement +- Dynamically list required status checks on target protected branch diff --git a/plugins/protected-branch/__tests__/protected-branch.test.ts b/plugins/protected-branch/__tests__/protected-branch.test.ts new file mode 100644 index 000000000..068d89ade --- /dev/null +++ b/plugins/protected-branch/__tests__/protected-branch.test.ts @@ -0,0 +1,223 @@ +import * as Auto from "@auto-it/core"; +import { dummyLog } from "@auto-it/core/dist/utils/logger"; +import { makeHooks } from "@auto-it/core/dist/utils/make-hooks"; +import ProtectedBranchPlugin from "../src"; + +const execPromise = jest.fn(); +jest.mock( + "../../../packages/core/dist/utils/exec-promise", + () => (...args: any[]) => execPromise(...args), +); + +describe("Protected-Branch Plugin", () => { + const mockGetSha = jest.fn(); + const mockCreateCheck = jest.fn(); + const mockCreatePr = jest.fn(); + const mockApprovePr = jest.fn(); + + function setupProtectedBranchPlugin( + checkEnv?: jest.SpyInstance, + withoutGit = false + ): { plugin: ProtectedBranchPlugin; hooks: Auto.IAutoHooks } { + const plugin = new ProtectedBranchPlugin({ reviewerToken: "token" }); + const hooks = makeHooks(); + + plugin.apply(({ + hooks, + checkEnv, + git: withoutGit + ? undefined + : { + getSha: mockGetSha, + github: { + checks: { create: mockCreateCheck }, + pulls: { + create: mockCreatePr, + createReview: mockApprovePr, + }, + }, + options: { + owner: "TheOwner", + repo: "my-repo", + }, + }, + logger: dummyLog(), + remote: "remote", + baseBranch: "main", + } as unknown) as Auto.Auto); + + return { plugin, hooks }; + } + + beforeEach(() => { + execPromise.mockReset(); + mockGetSha.mockReset().mockResolvedValueOnce("sha"); + mockCreateCheck.mockReset(); + mockCreatePr.mockReset().mockResolvedValueOnce({ data: { number: 42 } }); + mockApprovePr.mockReset(); + }); + + test("should setup FetchGitHistory Plugin hooks", () => { + const { hooks } = setupProtectedBranchPlugin(); + + expect(hooks.validateConfig.isUsed()).toBe(true); + expect(hooks.beforeRun.isUsed()).toBe(true); + expect(hooks.publish.isUsed()).toBe(true); + }); + + describe("validateConfig", () => { + test("should validate the configuration", async () => { + const { hooks, plugin } = setupProtectedBranchPlugin(); + await expect( + hooks.validateConfig.promise("not-me", {}) + ).resolves.toBeUndefined(); + await expect( + hooks.validateConfig.promise(plugin.name, {}) + ).resolves.toStrictEqual([]); + + const res = await hooks.validateConfig.promise(plugin.name, { + invalidKey: "value", + }); + expect(res).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + expect(res && res[0]).toContain(plugin.name); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + expect(res && res[0]).toContain("Found unknown configuration keys:"); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + expect(res && res[0]).toContain("invalidKey"); + }); + }); + + describe("beforeRun", () => { + test("should check env without image", async () => { + const checkEnv = jest.fn(); + const { hooks } = setupProtectedBranchPlugin(checkEnv); + await hooks.beforeRun.promise({ + plugins: [["protected-branch", {}]], + } as any); + expect(checkEnv).toHaveBeenCalledWith( + "protected-branch", + "PROTECTED_BRANCH_REVIEWER_TOKEN" + ); + }); + + test("shouldn't check env with image", async () => { + const checkEnv = jest.fn(); + const { hooks } = setupProtectedBranchPlugin(checkEnv); + await hooks.beforeRun.promise({ + plugins: [["protected-branch", { reviewerToken: "token" }]], + } as any); + expect(checkEnv).not.toHaveBeenCalled(); + }); + }); + + describe("publish", () => { + const options = { bump: Auto.SEMVER.patch }; + const commonGitArgs = { + owner: "TheOwner", + repo: "my-repo", + }; + + function expectCreateRemoteBranch(): void { + expect(execPromise).toHaveBeenNthCalledWith(1, "git", [ + "push", + "--set-upstream", + "remote", + "--porcelain", + "HEAD:automatic-release-sha", + ]); + expect(mockGetSha).toHaveBeenCalledTimes(1); + } + + function expectHandleBranchProtections(ciChecks: string[]): void { + expect(mockCreateCheck).toHaveBeenCalledTimes(ciChecks.length); + for (let i = 0; i < ciChecks.length; i++) { + expect(mockCreateCheck).toHaveBeenNthCalledWith(i + 1, { + ...commonGitArgs, + name: ciChecks[i], + head_sha: "sha", + conclusion: "success", + }); + } + + expect(mockCreatePr).toHaveBeenCalledWith({ + ...commonGitArgs, + base: "main", + head: "automatic-release-sha", + title: "Automatic release", + }); + expect(execPromise).toHaveBeenNthCalledWith(2, "gh", [ + "api", + "/repos/TheOwner/my-repo/pulls/42/reviews", + "-X", + "POST", + "-F", + "commit_id=sha", + "-F", + `event=APPROVE`, + ]); + } + + test("should do nothing without git", async () => { + const { hooks } = setupProtectedBranchPlugin(undefined, true); + + await expect(hooks.publish.promise(options)).resolves.toBeUndefined(); + + expect(execPromise).not.toHaveBeenCalled(); + expect(mockGetSha).not.toHaveBeenCalled(); + expect(mockCreateCheck).not.toHaveBeenCalled(); + expect(mockCreatePr).not.toHaveBeenCalled(); + expect(mockApprovePr).not.toHaveBeenCalled(); + }); + + test("should do nothing without reviewerToken", async () => { + const { hooks, plugin } = setupProtectedBranchPlugin(); + (plugin as any).options.reviewerToken = undefined; + + await expect(hooks.publish.promise(options)).resolves.toBeUndefined(); + + expect(execPromise).not.toHaveBeenCalled(); + expect(mockGetSha).not.toHaveBeenCalled(); + expect(mockCreateCheck).not.toHaveBeenCalled(); + expect(mockCreatePr).not.toHaveBeenCalled(); + expect(mockApprovePr).not.toHaveBeenCalled(); + }); + + test("should handle all branch protections", async () => { + const { hooks } = setupProtectedBranchPlugin(); + + await expect(hooks.publish.promise(options)).resolves.toBeUndefined(); + + expect(execPromise).toHaveBeenCalledTimes(2); + expectCreateRemoteBranch(); + expectHandleBranchProtections([]); + }); + + test("should handle ci branch protections", async () => { + const ciChecks = ["ci", "release"]; + + const { hooks, plugin } = setupProtectedBranchPlugin(); + (plugin as any).options.requiredStatusChecks = ciChecks; + + await expect(hooks.publish.promise(options)).resolves.toBeUndefined(); + + expect(execPromise).toHaveBeenCalledTimes(2); + expectCreateRemoteBranch(); + expectHandleBranchProtections(ciChecks); + }); + + test("should silently cleanup remote stuff", async () => { + const { hooks } = setupProtectedBranchPlugin(); + execPromise + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockRejectedValueOnce(new Error("couldn't delete remote branch")); + + await expect(hooks.publish.promise(options)).resolves.toBeUndefined(); + + expect(execPromise).toHaveBeenCalledTimes(2); + expectCreateRemoteBranch(); + expectHandleBranchProtections([]); + }); + }); +}); diff --git a/plugins/protected-branch/doc/branch-protection-1.png b/plugins/protected-branch/doc/branch-protection-1.png new file mode 100644 index 000000000..46db716db Binary files /dev/null and b/plugins/protected-branch/doc/branch-protection-1.png differ diff --git a/plugins/protected-branch/doc/branch-protection-2.png b/plugins/protected-branch/doc/branch-protection-2.png new file mode 100644 index 000000000..4c8da6edd Binary files /dev/null and b/plugins/protected-branch/doc/branch-protection-2.png differ diff --git a/plugins/protected-branch/package.json b/plugins/protected-branch/package.json new file mode 100644 index 000000000..acbef57f1 --- /dev/null +++ b/plugins/protected-branch/package.json @@ -0,0 +1,45 @@ +{ + "name": "@auto-it/protected-branch", + "version": "10.37.1", + "main": "dist/index.js", + "description": "Handle Github branch protections", + "license": "MIT", + "author": { + "name": "Andrew Lisowski", + "email": "lisowski54@gmail.com" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/intuit/auto" + }, + "files": [ + "dist" + ], + "keywords": [ + "automation", + "semantic", + "release", + "github", + "labels", + "automated", + "continuos integration", + "changelog" + ], + "scripts": { + "build": "tsc -b", + "start": "npm run build -- -w", + "lint": "eslint src --ext .ts", + "test": "jest --maxWorkers=2 --config ../../package.json" + }, + "dependencies": { + "@auto-it/core": "link:../../packages/core", + "@octokit/rest": "^18.12.0", + "fp-ts": "^2.5.3", + "io-ts": "^2.1.2", + "tslib": "1.10.0" + } +} diff --git a/plugins/protected-branch/src/GitOperator.ts b/plugins/protected-branch/src/GitOperator.ts new file mode 100644 index 000000000..8aa9e92c4 --- /dev/null +++ b/plugins/protected-branch/src/GitOperator.ts @@ -0,0 +1,98 @@ +import { execPromise } from "@auto-it/core"; +import Git from "@auto-it/core/dist/git"; +import { ILogger } from "@auto-it/core/dist/utils/logger"; +import { RestEndpointMethodTypes } from "@octokit/rest"; + +/** + * Utility class to handle all Git/Github related interactions + */ +export class GitOperator { + + /** Initialize this class */ + // eslint-disable-next-line no-useless-constructor + constructor(private readonly git: Git, private readonly logger: ILogger) {} + + /** Push HEAD to a remote branch */ + public async pushBranch(remote: string, branch: string): Promise { + await execPromise("git", [ + "push", + "--set-upstream", + remote, + "--porcelain", + `HEAD:${branch}`, + ]); + } + + /** Create a status check on a commit */ + public async createCheck(check: string, sha: string): Promise { + const params: RestEndpointMethodTypes["checks"]["create"]["parameters"] = { + name: check, + head_sha: sha, + conclusion: "success", + owner: this.git.options.owner, + repo: this.git.options.repo, + }; + + this.logger.verbose.info("Creating check using:\n", params); + + const result = await this.git.github.checks.create(params); + + this.logger.veryVerbose.info("Got response from createCheck\n", result); + this.logger.verbose.info("Created check on GitHub."); + } + + /** Create a pull request */ + public async createPr( + title: string, + head: string, + base: string + ): Promise { + const params: RestEndpointMethodTypes["pulls"]["create"]["parameters"] = { + title, + head, + base, + owner: this.git.options.owner, + repo: this.git.options.repo, + }; + + this.logger.verbose.info("Creating PullRequest using:\n", params); + + const result = await this.git.github.pulls.create(params); + + this.logger.veryVerbose.info("Got response from PullRequest\n", result); + this.logger.verbose.info("Created PullRequest on GitHub."); + + return result.data.number; + } + + /** Add an APPROVE review on a Pull Request */ + async approvePr( + token: string, + pull_number: number, + sha: string + ): Promise { + const oldToken = process.env.GITHUB_TOKEN; + try { + this.logger.verbose.info("Approving PullRequest using:\n", { + pull_number, + sha, + }); + + process.env.GITHUB_TOKEN = token; + await execPromise("gh", [ + "api", + `/repos/${this.git.options.owner}/${this.git.options.repo}/pulls/${pull_number}/reviews`, + "-X", + "POST", + "-F", + `commit_id=${sha}`, + "-F", + `event=APPROVE`, + ]); + } finally { + process.env.GITHUB_TOKEN = oldToken; + } + + this.logger.verbose.info("Approve Pull Request on GitHub."); + } +} diff --git a/plugins/protected-branch/src/index.ts b/plugins/protected-branch/src/index.ts new file mode 100644 index 000000000..2cf8a64bc --- /dev/null +++ b/plugins/protected-branch/src/index.ts @@ -0,0 +1,104 @@ +import { Auto, IPlugin, validatePluginConfiguration } from "@auto-it/core"; +import * as t from "io-ts"; +import { GitOperator } from "./GitOperator"; + +const pluginOptions = t.partial({ + /** Personal access token for who need to approve release commit changes */ + reviewerToken: t.string, + /** Branch prefix for release branch, default to "automatic-release-" */ + releaseTemporaryBranchPrefix: t.string, + /** List of required status checks for protected branch */ + requiredStatusChecks: t.array(t.string), +}); + +export type IProtectedBranchPluginOptions = t.TypeOf; + +/** Handle Github branch protections */ +export default class ProtectedBranchPlugin implements IPlugin { + /** The name of the plugin */ + name = "protected-branch"; + + /** The options of the plugin */ + readonly options: IProtectedBranchPluginOptions; + + /** Initialize the plugin with it's options */ + constructor(options: IProtectedBranchPluginOptions) { + this.options = { + reviewerToken: + options.reviewerToken || + process.env.PROTECTED_BRANCH_REVIEWER_TOKEN || + "", + releaseTemporaryBranchPrefix: + options.releaseTemporaryBranchPrefix || "automatic-release-", + requiredStatusChecks: options.requiredStatusChecks || [], + }; + } + + /** Tap into auto plugin points. */ + apply(auto: Auto) { + auto.hooks.validateConfig.tapPromise(this.name, async (name, options) => { + // If it's a string thats valid config + if (name === this.name && typeof options !== "string") { + return validatePluginConfiguration(this.name, pluginOptions, options); + } + }); + + auto.hooks.beforeRun.tap(this.name, (rc) => { + const protectedBranchPlugin = rc.plugins?.find( + (plugin) => + plugin[0] === this.name || plugin[0] === `@auto-it/${this.name}` + ) as [string, IProtectedBranchPluginOptions]; + if (!protectedBranchPlugin?.[1]?.reviewerToken) { + auto.checkEnv(this.name, "PROTECTED_BRANCH_REVIEWER_TOKEN"); + } + }); + + auto.hooks.publish.tapPromise( + { + name: this.name, + // Include this plugin in a high priority stage in order to be mostly often before others plugins + stage: -1, + }, + async () => { + if (!auto.git || !this.options.reviewerToken) { + return; + } + + const gitOperator = new GitOperator(auto.git, auto.logger); + const sha = await auto.git.getSha(); + const headBranch = `${this.options.releaseTemporaryBranchPrefix}${sha}`; + + auto.logger.log.info( + "Handling branch protection (without an admin token) 🔓 " + ); + + // First push this branch in order to open a PR on it (needed by protections) + await gitOperator.pushBranch(auto.remote, headBranch); + + // As github-actions (with checks: write) + auto.logger.log.info("Handle branch protection (required checks) 🕵️ "); + await Promise.all( + (this.options.requiredStatusChecks || []).map((check) => + gitOperator.createCheck(check, sha) + ) + ); + + // As github-actions (pull-requests: write) + auto.logger.log.info("Open a release PR to handle changes 🐙 "); + const prNumber = await gitOperator.createPr( + "Automatic release", + headBranch, + auto.baseBranch + ); + + // As reviewer, allowed in : `Restrict who can push to matching branches` + auto.logger.log.info("Mark released pr as reviewed ✅ "); + await gitOperator.approvePr(this.options.reviewerToken, prNumber, sha); + + auto.logger.log.info( + `Branch protection handled in PR ${prNumber}, Follow up 🚀 ` + ); + } + ); + } +} diff --git a/plugins/protected-branch/tsconfig.json b/plugins/protected-branch/tsconfig.json new file mode 100644 index 000000000..bfbef6fc7 --- /dev/null +++ b/plugins/protected-branch/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "../../typings/**/*"], + + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + + "references": [ + { + "path": "../../packages/core" + } + ] +} diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 027fb450a..f9aa43eb0 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -94,6 +94,9 @@ }, { "path": "plugins/version-file" + }, + { + "path": "plugins/protected-branch" } ] -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 6b5d6f99c..55cf25a95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13,10 +13,10 @@ integrity sha512-K1kQv1BZVtMXQqdpNZt9Pgh85KwamsWX9gYyq1xG4cpyb+EacfMiNfumrju16piFXanCUrCR0P1DowPjV2qV/A== "@auto-it/bot-list@link:packages/bot-list": - version "10.37.4" + version "10.37.6" "@auto-it/core@link:packages/core": - version "10.37.4" + version "10.37.6" dependencies: "@auto-it/bot-list" "link:packages/bot-list" "@endemolshinegroup/cosmiconfig-typescript-loader" "^3.0.2" @@ -60,7 +60,7 @@ url-join "^4.0.0" "@auto-it/npm@link:plugins/npm": - version "10.37.4" + version "10.37.6" dependencies: "@auto-it/core" "link:packages/core" "@auto-it/package-json-utils" "link:packages/package-json-utils" @@ -78,13 +78,13 @@ user-home "^2.0.0" "@auto-it/package-json-utils@link:packages/package-json-utils": - version "10.37.4" + version "10.37.6" dependencies: parse-author "^2.0.0" parse-github-url "1.0.2" "@auto-it/released@link:plugins/released": - version "10.37.4" + version "10.37.6" dependencies: "@auto-it/bot-list" "link:packages/bot-list" "@auto-it/core" "link:packages/core" @@ -94,7 +94,7 @@ tslib "2.1.0" "@auto-it/version-file@link:plugins/version-file": - version "10.37.4" + version "10.37.6" dependencies: "@auto-it/core" "link:packages/core" fp-ts "^2.5.3"