Skip to content

Commit

Permalink
feat(protected-branch): initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
jBouyoud committed Jun 3, 2022
1 parent 91e0be2 commit 80b635e
Show file tree
Hide file tree
Showing 11 changed files with 610 additions and 12 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
104 changes: 104 additions & 0 deletions plugins/protected-branch/README.md
Original file line number Diff line number Diff line change
@@ -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 (`[email protected]`)
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 [email protected]
CHANGELOG.md @prg/owner-team [email protected]
```

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.<<YOUR-GITHUB-ACTIONS-SECRET-NAME>> }}
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
223 changes: 223 additions & 0 deletions plugins/protected-branch/__tests__/protected-branch.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions plugins/protected-branch/package.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]"
},
"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"
}
}
Loading

0 comments on commit 80b635e

Please sign in to comment.