Skip to content
Open
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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,12 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d
| `admins_pat` | `false` | `"false"` | A GitHub personal access token with "read:org" scopes. This is only needed if you are using the "admins" option with a GitHub org team. For example: "my-org/my-team" |
| `merge_deploy_mode` | `false` | `false` | Advanced configuration option for operations on merge commits. See the [merge commit docs](#merge-commit-workflow-strategy) below |
| `unlock_on_merge_mode` | `false` | `false` | Advanced configuration option for automatically releasing locks associated with a pull request when that pull request is merged. See the [unlock on merge mode](docs/unlock-on-merge.md) documentation for more details |
| `skip_completing` | `false` | `false` | If set to `true`, bypass the entire post-action completion path. Your workflow must manage final deployment status, comments, reactions, labels, and non-sticky lock cleanup. Default is `false`. |
| `skip_completing` | `false` | `false` | If set to `true`, bypass the initial invocation's post-action completion path. Use [result mode](docs/result-mode.md) in a later job, or manage final statuses, comments, reactions, labels, and safe non-sticky lock cleanup yourself. |
| `result_mode` | `false` | `false` | Complete an admitted deployment or noop in a later job of the same workflow run and attempt. Requires `context` and `job_results`. See [result mode](docs/result-mode.md). |
| `context` | `false` | `""` | The original start job's `context` output, passed unchanged to result mode. Do not construct it or read it from pull request files or build artifacts. |
| `job_results` | `false` | `""` | A nonempty JSON array of selected job results for result mode, such as `${{ toJSON(needs.*.result) }}`. Accepted values are `success`, `failure`, `cancelled`, and `skipped`; do not pass the full `needs` object. |
| `result_inherit_settings` | `false` | `true` | Use the initial invocation's completion settings. If `false`, replace them with the result invocation's normal completion inputs and defaults, without merging omitted values from the initial invocation. |
| `result_url` | `false` | `""` | Optional HTTPS result link. Credentials and control characters are rejected, and the URL is never fetched. |
| `deploy_message_path` | `false` | `".github/deployment_message.md"` | The repository-relative path to a trusted Markdown template for custom deployment messages. Branch Deploy fetches the file from the repository at the exact trusted workflow SHA; absolute paths, traversal segments, and runner filesystem paths are rejected. See the [custom deployment messages documentation](docs/custom-deployment-messages.md). |
| `sticky_locks` | `false` | `false` | If set to `true`, locks will not be released after a deployment run completes. This applies to both successful, and failed deployments. Sticky locks are also known as ["hubot style deployment locks"](./docs/hubot-style-deployment-locks.md). They will persist until they are manually released by a user, or if you configure [another workflow with the "unlock on merge" mode](./docs/unlock-on-merge.md) to remove them automatically on PR merge. |
| `sticky_locks_for_noop` | `false` | `false` | If set to `true`, then sticky_locks will also be used for noop deployments. This can be useful in some cases but it often leads to locks being left behind when users test noop deployments. |
Expand All @@ -328,6 +333,8 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d
| `decision` | The preferred main action decision output. Values are `continue`, `complete`, `stop`, or `failure`. |
| `reason_code` | The preferred stable machine-readable reason code for the main action decision. |
| `result` | The preferred deterministic JSON string describing the versioned main action result. Parse it with `fromJSON(...)` in workflow expressions. |
| `context` | JSON completion context for a later result-mode job in the same workflow run and attempt. Forward it unchanged from the trusted start job; its `run_attempt` also guards downstream jobs against stale partial reruns. See [result mode](docs/result-mode.md). |
| `deployment_result` | The selected job outcome in result mode: `success`, `failure`, `cancelled`, or `skipped`. Reporting or cleanup can fail independently of this outcome. See [result mode](docs/result-mode.md). |
| `continue` | Compatibility alias. The string "true" if the deployment should continue, otherwise empty - Use this to conditionally control if your deployment should proceed or not |
| `fork` | The string "true" if the pull request is a fork, otherwise "false" |
| `triggered` | The string "true" if the trigger was found, otherwise the string "false" |
Expand Down Expand Up @@ -592,11 +599,11 @@ Checkout the [merge commit workflow strategy](docs/merge-commit-strategy.md) for

## Manual Deployment Control

If you need fine-grained control over completion, set `skip_completing: true` to bypass Branch Deploy's entire post-action completion path.
For deployments that span jobs, set `skip_completing: true` on the initial invocation and use [result mode](docs/result-mode.md) in a final job. It handles deployment and noop completion using the original context. See the [multiple-jobs example](docs/examples.md#multiple-jobs).

When using this option, your workflow is responsible for the final deployment status and any required completion comments, reactions, labels, and non-sticky lock cleanup. Branch Deploy does not perform those operations after the deployment jobs finish.
If you need full manual control, `skip_completing: true` without a result invocation still bypasses completion. Your workflow is then responsible for final deployment statuses, comments, reactions, labels, and safe non-sticky lock cleanup. Existing single-job workflows keep their normal post-action completion when this option is `false`.

An example workflow using this option can be found [here](https://github.com/github/branch-deploy/blob/main/docs/examples.md#multiple-jobs)
The [manual multi-job examples](docs/examples.md#multiple-jobs-with-github-pages-and-hugo) remain available for that case.

## Saving on Actions Compute

Expand Down
13 changes: 10 additions & 3 deletions __tests__/action-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const expectedInputContract = {
merge_deploy_mode: {default: 'false', required: false},
unlock_on_merge_mode: {default: 'false', required: false},
skip_completing: {default: 'false', required: false},
result_mode: {default: 'false', required: false},
context: {default: '', required: false},
job_results: {default: '', required: false},
result_inherit_settings: {default: 'true', required: false},
result_url: {default: '', required: false},
deploy_message_path: {
default: '.github/deployment_message.md',
required: false
Expand Down Expand Up @@ -101,6 +106,8 @@ const expectedBooleanInputKeys = [
'merge_deploy_mode',
'unlock_on_merge_mode',
'skip_completing',
'result_mode',
'result_inherit_settings',
'sticky_locks',
'sticky_locks_for_noop',
'disable_lock',
Expand Down Expand Up @@ -172,8 +179,8 @@ test('action input and output registries exactly match action.yml', () => {
[...ACTION_OUTPUT_KEYS].sort(),
Object.keys(outputs).sort()
)
assert.strictEqual(ACTION_INPUT_KEYS.length, 51)
assert.strictEqual(ACTION_OUTPUT_KEYS.length, 41)
assert.strictEqual(ACTION_INPUT_KEYS.length, 56)
assert.strictEqual(ACTION_OUTPUT_KEYS.length, 43)
})

test('action input defaults, required flags, and accepted literals stay fixed', () => {
Expand Down Expand Up @@ -204,7 +211,7 @@ test('action input defaults, required flags, and accepted literals stay fixed',

test('typed input registries stay complete and exact', () => {
assert.deepStrictEqual(BOOLEAN_ACTION_INPUT_KEYS, expectedBooleanInputKeys)
assert.strictEqual(BOOLEAN_ACTION_INPUT_KEYS.length, 16)
assert.strictEqual(BOOLEAN_ACTION_INPUT_KEYS.length, 18)
assert.deepStrictEqual(INTEGER_ACTION_INPUT_KEYS, expectedIntegerInputKeys)
assert.deepStrictEqual(LITERAL_ACTION_INPUT_KEYS, expectedLiteralInputKeys)
assert.deepStrictEqual(LITERAL_ACTION_INPUT_VALUES, {
Expand Down
55 changes: 52 additions & 3 deletions __tests__/docs-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ test('manual deployment examples release only their original non-sticky locks',
match => match[0]
)

assert.strictEqual(captureSteps.length, 4)
assert.strictEqual(capturedOutputs.length, 4)
assert.strictEqual(releaseSteps.length, 4)
assert.strictEqual(captureSteps.length, 3)
assert.strictEqual(capturedOutputs.length, 3)
assert.strictEqual(releaseSteps.length, 3)

for (const step of releaseSteps) {
assert.match(step, /lock\.json\?ref=\$\{LOCK_REF_SHA\}/u)
Expand All @@ -177,6 +177,55 @@ test('manual deployment examples release only their original non-sticky locks',
}
})

test('the result-mode example uses trusted context and guards deployment reruns', () => {
const examples = readFileSync('docs/examples.md', 'utf8')
const example = /^## Multiple Jobs\n([\s\S]*?)(?=^## )/mu.exec(examples)?.[1]
assert.ok(example !== undefined)
assert.match(example, /skip_completing: true/u)
assert.match(
example,
/context: \$\{\{ steps\.branch-deploy\.outputs\.context \}\}/u
)
assert.match(
example,
/fromJSON\(needs\.trigger\.outputs\.context\)\.run_attempt == github\.run_attempt/u
)
assert.match(
example,
/if: \$\{\{ needs\.trigger\.outputs\.noop == 'true' \}\}/u
)
assert.match(
example,
/if: \$\{\{ needs\.trigger\.outputs\.noop != 'true' \}\}/u
)
const resultJob = /^ {2}result:\n([\s\S]*?)^```/mu.exec(example)?.[1]
assert.ok(resultJob !== undefined)
assert.match(resultJob, /needs: \[trigger, deploy\]/u)
assert.match(
resultJob,
/always\(\) && needs\.trigger\.outputs\.continue == 'true'/u
)
assert.match(resultJob, /result_mode: true/u)
assert.match(
resultJob,
/context: \$\{\{ needs\.trigger\.outputs\.context \}\}/u
)
assert.match(
resultJob,
/job_results: \$\{\{ toJSON\(needs\.\*\.result\) \}\}/u
)
assert.doesNotMatch(
resultJob,
/actions\/checkout|download-artifact|toJSON\(needs\)/u
)
const pins = Array.from(
example.matchAll(/uses: github\/branch-deploy@(\S+)/gu),
match => match[1]
)
assert.strictEqual(pins.length, 2)
assert.strictEqual(pins[0], pins[1])
})

test('documented deployment messages do not use fixed delimiters', () => {
const unsafeDelimiters = documentedWorkflowFiles.flatMap(path =>
fixedDeploymentDelimiters(readFileSync(path, 'utf8')).map(
Expand Down
168 changes: 168 additions & 0 deletions __tests__/functions/deferred-completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict'
import {beforeEach, mock, test} from 'node:test'
import type {IssueCommentContext} from '../../src/types.ts'
import {createCompletionContext} from '../result-mode-fixtures.ts'
import {
assertCalledWith,
assertNotCalled,
createMock,
installModuleMock,
stubEnv
} from '../node-test-helpers.ts'

const actualCore = await import('../../src/actions-core.ts')
const warningMock = createMock<typeof actualCore.warning>()
const setOutputMock = createMock<typeof actualCore.setOutput>()
installModuleMock(mock, new URL('../../src/actions-core.ts', import.meta.url), {
...actualCore,
warning: warningMock,
setOutput: setOutputMock
})

const {
completionSettings,
deferredCompletionMetadata,
deferredCompletionRequested,
publishCompletionContext
} = await import('../../src/functions/deferred-completion.ts')
const {MAX_COMPLETION_CONTEXT_BYTES, parseCompletionContext} =
await import('../../src/functions/result-context.ts')

const context: IssueCommentContext = {
actor: 'octocat',
eventName: 'issue_comment',
repo: {owner: 'octocat', repo: 'example'},
runId: 1200,
issue: {number: 7},
payload: {
issue: {number: 7, pull_request: {}},
comment: {
id: 20,
body: '.deploy',
created_at: '2026-01-02T03:04:05.000Z',
updated_at: '2026-01-02T03:04:05.000Z',
html_url: 'https://github.com/octocat/example/pull/7#issuecomment-20',
user: {login: 'octocat'}
}
}
}

const request = {
context,
trustedSha: '1'.repeat(40),
lockRefSha: '2'.repeat(40),
disableLock: false
}

beforeEach(testContext => {
if (!('after' in testContext)) throw new TypeError('Expected a test context')
warningMock.mock.resetCalls()
setOutputMock.mock.resetCalls()
stubEnv(testContext, 'INPUT_SKIP_COMPLETING', 'true')
stubEnv(testContext, 'GITHUB_RUN_ATTEMPT', '1')
})

for (const value of ['true', 'True', 'TRUE']) {
test(`recognizes the existing true spelling ${value}`, testContext => {
stubEnv(testContext, 'INPUT_SKIP_COMPLETING', value)
assert.strictEqual(deferredCompletionRequested(), true)
})
}

for (const value of ['', 'false', 'False', 'FALSE', 'invalid', '1']) {
test(`leaves non-true skip_completing input ${JSON.stringify(value)} alone`, testContext => {
stubEnv(testContext, 'INPUT_SKIP_COMPLETING', value)
assert.strictEqual(deferredCompletionRequested(), false)
assert.strictEqual(deferredCompletionMetadata(request), null)
assertNotCalled(warningMock)
})
}

test('captures only the original operation identity and lock reference', () => {
assert.deepStrictEqual(deferredCompletionMetadata(request), {
schema_version: 1,
repository: 'octocat/example',
run_id: 1200,
run_attempt: 1,
issue_number: 7,
trigger_comment_id: 20,
trusted_sha: '1'.repeat(40),
lock_ref_sha: '2'.repeat(40),
disable_lock: false
})
})

test('supports a deferred operation without a lock', () => {
const metadata = deferredCompletionMetadata({
...request,
lockRefSha: undefined,
disableLock: true
})
assert.strictEqual(metadata?.disable_lock, true)
assert.strictEqual(metadata.lock_ref_sha, null)
})

for (const value of [undefined, '', '0', '-1', '1.5', 'invalid']) {
test(`keeps manual completion working when the attempt is ${String(value)}`, testContext => {
stubEnv(testContext, 'GITHUB_RUN_ATTEMPT', value)
assert.strictEqual(deferredCompletionMetadata(request), null)
assertCalledWith(
warningMock,
'completion context is unavailable; complete this deployment manually'
)
})
}

test('keeps manual completion working without a trusted workflow SHA', () => {
assert.strictEqual(
deferredCompletionMetadata({...request, trustedSha: undefined}),
null
)
assertCalledWith(
warningMock,
'completion context is unavailable; complete this deployment manually'
)
})

test('copies completion settings without validating them before the legacy post hook', testContext => {
const settings = {
deploy_message_path: 'custom-result.md',
environment_url_in_comment: 'invalid',
successful_deploy_labels: 'deployed',
failed_deploy_labels: 'deploy-failed',
successful_noop_labels: 'noop-complete',
failed_noop_labels: 'noop-failed',
skip_successful_noop_labels_if_approved: 'invalid',
skip_successful_deploy_labels_if_approved: 'invalid'
}
for (const [key, value] of Object.entries(settings)) {
stubEnv(testContext, `INPUT_${key.toUpperCase()}`, value)
}
assert.deepStrictEqual(completionSettings(), settings)
assertNotCalled(warningMock)
})

test('publishes a ready context that the receiver can validate', () => {
const ready = createCompletionContext()
publishCompletionContext(ready)
const call = setOutputMock.mock.calls.at(-1)
assert.ok(call !== undefined)
assert.strictEqual(call.arguments[0], 'context')
assert.strictEqual(typeof call.arguments[1], 'string')
assert.deepStrictEqual(
parseCompletionContext(String(call.arguments[1])),
ready
)
assertNotCalled(warningMock)
})

test('omits oversized context without breaking existing manual completion', () => {
publishCompletionContext(
createCompletionContext({params: 'x'.repeat(MAX_COMPLETION_CONTEXT_BYTES)})
)
assertNotCalled(setOutputMock)
assertCalledWith(
warningMock,
'completion context is too large; complete this deployment manually'
)
})
Loading