Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create Protected branch plugin #2210

Merged
merged 3 commits into from Feb 4, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
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
@@ -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.<<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
@@ -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([]);
});
});
});
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
@@ -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"
}
}