-
Notifications
You must be signed in to change notification settings - Fork 207
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(protected-branch): initial release
- Loading branch information
Showing
11 changed files
with
610 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 : | ||
|
||
 | ||
 | ||
|
||
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
223
plugins/protected-branch/__tests__/protected-branch.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Oops, something went wrong.