branch-deploy workflows commonly use the issue_comment event so deployment
commands can be driven by pull request comments such as .noop and .deploy.
That event has an important security property: GitHub evaluates the workflow
file from the repository's default branch, not from the pull request branch.
That protects the workflow definition itself, but it does not automatically
protect code you later check out and execute. If your workflow checks out the
pull request SHA and then runs helper scripts such as script/deploy,
script/ci/update_deploy_msg.py, or a deployment message template from that
checkout, those helpers are controlled by the pull request until the PR is
reviewed and merged.
A trusted checkout separates those concerns:
- Trusted checkout: the default-branch workflow commit. Use this for deployment orchestration, helper scripts, and deployment message templates.
- Working checkout: the exact commit SHA selected by branch-deploy. Use this for the application, infrastructure, or other content you intend to deploy.
This pattern keeps PR-controlled code deployable while preventing that same PR from changing the deployment helper code that decides how deployment happens.
Use trusted checkouts when your branch-deploy workflow does both of these:
- checks out pull request content with
steps.branch-deploy.outputs.sha - executes repository-owned helper code or reads templates after that checkout
Common examples include:
script/deploy,script/release, orscript/ci/*helper scripts- custom deployment message templates with
deploy_message_path - scripts that transform Terraform plan/apply output before branch-deploy posts the final deployment comment
- deployment wrappers that set cloud CLI arguments, select targets, or prepare credentials
If all deployment logic is inline in the workflow file, and the workflow uses the
issue_comment event, you already get the default-branch workflow-file
protection. Trusted checkouts are most useful once deployment behavior moves
into checked-out files.
A hardened workflow usually follows this sequence:
- Derive a trusted checkout path from the repository default branch.
- Run
github/branch-deployfrom the default-branch workflow. - Validate
steps.branch-deploy.outputs.shaas an exact commit SHA. - Derive a working checkout path from that SHA.
- Check out trusted helper/template code at
github.sha. - Check out working deployment code at
steps.branch-deploy.outputs.sha. - Verify both checkout
HEADvalues before running deployment commands. - Run deployment commands from the working checkout.
- Run helper scripts and deployment message rendering from the trusted checkout.
The important rule is simple: deployment helper code should come from the trusted checkout, while deployable project content should come from the working checkout.
Use separate directories for the two checkouts. A practical convention is:
- trusted checkout directory: derived from
github.event.repository.default_branch - working checkout directory:
deployment-${FULL_SHA}
The trusted directory should be sanitized before use as a filesystem path. The
working directory should be derived only after validating that
steps.branch-deploy.outputs.sha is a 40-character Git SHA, or a 64-character
SHA if your organization uses SHA-256 repositories.
The workflow should fail if either derived directory is empty, unsafe, or collides with the other checkout directory.
For both checkouts, prefer shallow checkouts and avoid persisting credentials:
with:
fetch-depth: 1
persist-credentials: falseUse the exact sha output from branch-deploy for working code:
with:
ref: ${{ steps.branch-deploy.outputs.sha }}Do not use a mutable branch ref for deployments unless you have a specific reason to do so. See Deploying Commit SHAs for more detail.
For the trusted checkout in an issue_comment workflow, github.sha points to
the last commit on the repository's default branch:
with:
ref: ${{ github.sha }}If you use deploy_message_path, point it at the trusted checkout:
with:
deploy_message_path: ${{ steps.trusted-path.outputs.trusted_dir }}/.github/deployment_message.mdThat prevents a pull request from changing the template that branch-deploy will render for its own deployment result.
If deployment output is inserted into a Nunjucks-rendered template, escape any user-controlled or tool-generated content before inserting it. At minimum, escape Nunjucks opening delimiters:
{{{%{#
See Custom Deployment Messages for details on deployment message rendering.
Avoid writing generated deployment output into the working checkout when a
trusted helper will read it later. Instead, create an output file under
RUNNER_TEMP and pass that absolute path to the trusted helper:
- name: prepare deployment output path
id: deployment-output
run: |
set -euo pipefail
output_dir="$(mktemp -d "${RUNNER_TEMP}/branch-deploy-output.XXXXXX")"
output_path="${output_dir}/deployment-output.txt"
: > "${output_path}"
if [[ -L "${output_path}" || ! -f "${output_path}" ]]; then
echo "deployment output path is not a regular file: ${output_path}" >&2
exit 1
fi
echo "path=${output_path}" >> "${GITHUB_OUTPUT}"
echo "deployment output path: ${output_path}"Then run the helper from the trusted checkout:
- name: update deploy comment
working-directory: ${{ steps.trusted-path.outputs.trusted_dir }}
env:
RESULTS_PATH: ${{ steps.deployment-output.outputs.path }}
run: python3 script/ci/update_deploy_msg.pyTrusted checkouts work well with other branch-deploy safety settings:
- Set
allow_forks: "false"if your project does not need fork deployments. - Use branch protection, pull request reviews, and required status checks.
- Use
commit_verification: "true"if your project requires verified commits. - Always use the
shaoutput for deployment checkouts.
For Terraform or other tools with shared remote state, use GitHub Actions concurrency to avoid state-lock races. For example:
concurrency:
group: terraform-production
cancel-in-progress: false
queue: maxApply that shared group only to jobs that touch the same remote state. Support
commands such as .help, .lock, .unlock, and .wcid can use a unique
per-run concurrency group or no concurrency group so they stay responsive.
For a complete sanitized workflow set using this pattern with Terraform, see Terraform with Trusted Checkouts.
That example includes:
- a branch deploy workflow with trusted and working checkouts
- a merge deploy workflow using
merge_deploy_mode - an unlock-on-merge workflow
- a trusted deployment message template
- a trusted helper script for inserting Terraform output into the template
Related docs: