Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d
| `skip_successful_noop_labels_if_approved` | `false` | `false` | Whether or not the post run logic should skip adding successful noop labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.noop` completes but want to skip adding that label in situations where the pull request is already approved. |
| `skip_successful_deploy_labels_if_approved` | `false` | `false` | Whether or not the post run logic should skip adding successful deploy labels if the pull request is approved. This can be useful if you add a label such as "ready-for-review" after a `.deploy` completes but want to skip adding that label in situations where the pull request is already approved. |
| `enforced_deployment_order` | `false` | `""` | A comma separated list of environments that must be deployed in a specific order. Example: `"development,staging,production"`. If this is set then you cannot deploy to latter environments unless the former ones have a successful and active deployment on the latest commit first - See the [enforced deployment order docs](./docs/enforced-deployment-order.md) for more details |
| `deployment_order_scope` | `false` | `all` | Controls which deployment history records are considered by enforced deployment order. Use `all` to keep the newest deployment from any system authoritative, or `branch-deploy` to use the newest deployment whose payload identifies it as Branch Deploy. The `branch-deploy` scope ignores newer deployments from other systems and should be used only when Branch Deploy is authoritative for promotion. See the [enforced deployment order docs](./docs/enforced-deployment-order.md) for details. |
| `use_security_warnings` | `false` | `true` | Whether or not to leave security related warnings in log messages during deployments. Default is `true` |
| `allow_non_default_target_branch_deployments` | `false` | `false` | Whether or not to allow deployments of pull requests that target a branch other than the default branch (aka stable branch) as their merge target. By default, this Action would reject the deployment of a branch named `feature-branch` if it was targeting `foo` instead of `main` (or whatever your default branch is). This option allows you to override that behavior and be able to deploy any branch in your repository regardless of the target branch. This option is potentially unsafe and should be used with caution as most default branches contain branch protection rules. Often times non-default branches do not contain these same branch protection rules. Follow along in this [issue thread](https://github.com/github/branch-deploy/issues/340) to learn more. |
| `deployment_confirmation` | `false` | `false` | Whether or not to require an additional confirmation before a deployment can continue. Default is `false`. If your project requires elevated security, it is highly recommended to enable this option - especially in open source projects where you might be deploying forks - [Deployment confirmation docs](./docs/deployment-confirmation.md) |
Expand Down
14 changes: 11 additions & 3 deletions __tests__/action-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../src/action-io.ts'
import {
CHECKS_MODE_VALUES,
DEPLOYMENT_ORDER_SCOPE_VALUES,
LITERAL_ACTION_INPUT_KEYS,
LITERAL_ACTION_INPUT_VALUES,
OUTDATED_MODE_VALUES,
Expand Down Expand Up @@ -83,6 +84,7 @@ const expectedInputContract = {
required: false
},
enforced_deployment_order: {default: '', required: false},
deployment_order_scope: {default: 'all', required: false},
use_security_warnings: {default: 'true', required: false},
allow_non_default_target_branch_deployments: {
default: 'false',
Expand Down Expand Up @@ -118,7 +120,8 @@ const expectedIntegerInputKeys = [
const expectedLiteralInputKeys = [
'update_branch',
'outdated_mode',
'checks'
'checks',
'deployment_order_scope'
] as const satisfies readonly ActionInputKey[]

function requireRecord(value: unknown, label: string): Record<string, unknown> {
Expand Down Expand Up @@ -169,7 +172,7 @@ test('action input and output registries exactly match action.yml', () => {
[...ACTION_OUTPUT_KEYS].sort(),
Object.keys(outputs).sort()
)
assert.strictEqual(ACTION_INPUT_KEYS.length, 50)
assert.strictEqual(ACTION_INPUT_KEYS.length, 51)
assert.strictEqual(ACTION_OUTPUT_KEYS.length, 41)
})

Expand All @@ -193,6 +196,10 @@ test('action input defaults, required flags, and accepted literals stay fixed',
'strict'
])
assert.deepStrictEqual(CHECKS_MODE_VALUES, ['all', 'required'])
assert.deepStrictEqual(DEPLOYMENT_ORDER_SCOPE_VALUES, [
'all',
'branch-deploy'
])
})

test('typed input registries stay complete and exact', () => {
Expand All @@ -203,7 +210,8 @@ test('typed input registries stay complete and exact', () => {
assert.deepStrictEqual(LITERAL_ACTION_INPUT_VALUES, {
update_branch: ['disabled', 'warn', 'force'],
outdated_mode: ['pr_base', 'default_branch', 'strict'],
checks: ['all', 'required']
checks: ['all', 'required'],
deployment_order_scope: ['all', 'branch-deploy']
})

const registeredInputs: ReadonlySet<string> = new Set(ACTION_INPUT_KEYS)
Expand Down
129 changes: 121 additions & 8 deletions __tests__/functions/deployment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {afterEach, beforeEach, mock, test} from 'node:test'
import {API_HEADERS} from '../../src/functions/api-headers.ts'
import {createContext} from '../test-helpers.ts'
import {
assertCalledWith,
assertCalledTimes,
createMock,
installModuleMock
Expand All @@ -18,9 +19,11 @@ type DeploymentNode = NonNullable<
>['deployments']['nodes'][number]

const debugMock = createMock<ActionsCore['debug']>()
const warningMock = createMock<ActionsCore['warning']>()

installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), {
debug: debugMock
debug: debugMock,
warning: warningMock
})

const {
Expand Down Expand Up @@ -76,6 +79,7 @@ const originalServerUrl = process.env['GITHUB_SERVER_URL']

beforeEach(() => {
debugMock.mock.resetCalls()
warningMock.mock.resetCalls()
createDeploymentStatusMock.mock.resetCalls()
graphqlMock.mock.resetCalls()
process.env['GITHUB_SERVER_URL'] = 'https://github.com'
Expand Down Expand Up @@ -238,7 +242,13 @@ test('returns false if the deployment is not active', async () => {
)

assert.strictEqual(
await activeDeployment(graphqlOctokit, context, environment, 'sha'),
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'all',
sha: 'sha'
}),
false
)
assertCalledTimes(graphqlMock, 1)
Expand All @@ -252,7 +262,13 @@ test('returns false if the deployment does not match the sha', async () => {
)

assert.strictEqual(
await activeDeployment(graphqlOctokit, context, environment, 'sha'),
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'all',
sha: 'sha'
}),
false
)
assertCalledTimes(graphqlMock, 1)
Expand All @@ -264,12 +280,13 @@ test('returns true if the deployment is active and matches the sha', async () =>
)

assert.strictEqual(
await activeDeployment(
graphqlOctokit,
await activeDeployment({
context,
environment,
'315cec138fc9d7dac8a47c6bba4217d3965ede3b'
),
octokit: graphqlOctokit,
scope: 'all',
sha: '315cec138fc9d7dac8a47c6bba4217d3965ede3b'
}),
true
)
assertCalledTimes(graphqlMock, 1)
Expand All @@ -279,12 +296,104 @@ test('returns false if the deployment is not found', async () => {
graphqlMock.mock.mockImplementation(() => Promise.resolve(deploymentPage([])))

assert.strictEqual(
await activeDeployment(graphqlOctokit, context, environment, 'sha'),
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'all',
sha: 'sha'
}),
false
)
assertCalledTimes(graphqlMock, 1)
})

test('branch-deploy scope ignores a newer unrelated deployment', async () => {
const unrelatedDeployment = {
...activeDeploymentNode,
id: 'automatic-deployment',
payload: null,
commit: {oid: 'default-branch-sha'}
}
graphqlMock.mock.mockImplementation(() =>
Promise.resolve(deploymentPage([unrelatedDeployment, activeDeploymentNode]))
)

assert.strictEqual(
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'branch-deploy',
sha: '315cec138fc9d7dac8a47c6bba4217d3965ede3b'
}),
true
)
assert.deepStrictEqual(graphqlMock.mock.calls[0]?.arguments[1], {
repo_owner: 'corp',
repo_name: 'test',
environment,
first: 100,
cursor: null
})
})

test('all scope keeps a newer unrelated deployment authoritative', async () => {
graphqlMock.mock.mockImplementation(() =>
Promise.resolve(
deploymentPage([
{
...activeDeploymentNode,
id: 'automatic-deployment',
payload: null,
commit: {oid: 'default-branch-sha'}
},
activeDeploymentNode
])
)
)

assert.strictEqual(
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'all',
sha: '315cec138fc9d7dac8a47c6bba4217d3965ede3b'
}),
false
)
assert.deepStrictEqual(graphqlMock.mock.calls[0]?.arguments[1], {
repo_owner: 'corp',
repo_name: 'test',
environment,
first: 1,
cursor: null
})
})

for (const deployment of [
{...activeDeploymentNode, state: 'INACTIVE'},
{...activeDeploymentNode, commit: {oid: 'different-sha'}}
] as const) {
test(`branch-deploy scope rejects its newest ${deployment.state === 'INACTIVE' ? 'inactive' : 'different-SHA'} deployment`, async () => {
graphqlMock.mock.mockImplementation(() =>
Promise.resolve(deploymentPage([deployment, activeDeploymentNode]))
)

assert.strictEqual(
await activeDeployment({
context,
environment,
octokit: graphqlOctokit,
scope: 'branch-deploy',
sha: '315cec138fc9d7dac8a47c6bba4217d3965ede3b'
}),
false
)
})
}

test('paginates with cursor variables to find the newest branch-deploy deployment', async () => {
graphqlMock.mock.mockImplementationOnce(
() =>
Expand Down Expand Up @@ -405,6 +514,10 @@ for (const payload of [
await latestBranchDeployDeployment(graphqlOctokit, context, environment),
null
)
assertCalledWith(
warningMock,
'deployment history for production contains a malformed payload; refusing to search older records'
)
})
}

Expand Down
13 changes: 12 additions & 1 deletion __tests__/functions/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ test('successfully calls help with non-defaults again', async () => {
checks: 'required',
ignored_checks: ['lint'],
commit_verification: false,
deployment_order_scope: 'branch-deploy',
enforced_deployment_order: ['development', 'staging', 'production'],
use_security_warnings: false,
allow_non_default_target_branch_deployments: false
Expand All @@ -166,11 +167,21 @@ test('successfully calls help with non-defaults again', async () => {
assertDebugMatches(/## 📚 Branch Deployment Help/)

assertDebugMatches(/a specific deployment order by environment/)
assertDebugMatches(
/Only deployments whose payload identifies them as Branch Deploy count toward this order; newer deployments from other systems are ignored/
)

const inputsSecond = {...inputs, update_branch: 'disabled'} as const
const inputsSecond = {
...inputs,
deployment_order_scope: 'all',
update_branch: 'disabled'
} as const
assert.strictEqual(await help(octokit, context, 123, inputsSecond), undefined)

assertDebugMatches(/## 📚 Branch Deployment Help/)
assertDebugMatches(
/The newest deployment from any system counts toward this order/
)
})

test('successfully calls help with non-defaults and unknown update_branch setting', async () => {
Expand Down
Loading
Loading