diff --git a/README.md b/README.md index 8e73685e..7543fddd 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,8 @@ on: Unlike the `on: pull_request` trigger, the `on: issue_comment` trigger only uses Actions workflow files from the default branch in GitHub. This means that a bad actor cannot open a PR with a malicious workflow edit and dump secrets, trigger bad deployments, or cause other issues. This means that any changes to the workflow files can be protected with branch protection rules to ensure only verified changes make it into your default branch. +If your workflow checks out pull request code and then runs deployment helper scripts, templates, or other orchestration code from that checkout, review the [trusted checkout hardening guide](docs/trusted-checkouts.md). It explains how to run trusted helper code from your default branch while still deploying the exact working commit selected by branch-deploy. + To further harden your workflow files, it is strongly suggested to include the base permissions that this Action needs to run: ```yaml @@ -505,6 +507,7 @@ Here are some additional security best practices to consider: - Ensure that your branch protection settings require that PRs have approvals before. This prevents users from deploying changes that have not been reviewed. - Ensure that your branch protection settings require that PRs have some CI checks defined, and that those CI checks are required. This ensure that the code being deployed has passing CI checks. - Set the [`deployment_confirmation: true`](./docs/deployment-confirmation.md) input option to require a final safety check of human approval before each deployment can continue. Ensure that you review the sha being used in the deployment confirmation comment with the sha that you expect to be deployed. +- Use a [trusted checkout](docs/trusted-checkouts.md) for deployment helper code and templates when your workflow also checks out pull request code for deployment. ### Admins 👩‍🔬 @@ -626,6 +629,7 @@ jobs: This section contains real world examples of how this Action can be used - [Terraform](docs/examples.md#terraform) +- [Terraform with Trusted Checkouts](docs/examples.md#terraform-with-trusted-checkouts) - [Heroku](docs/examples.md#heroku) - [Railway](docs/examples.md#railway) - [SSH](docs/examples.md#ssh) @@ -666,6 +670,7 @@ This section will cover a few suggestions and best practices that will help you ![use-status-checks](./docs/assets/required-ci-checks.png) 3. If you don't need to deploy PR forks (perhaps your project is internal and not open source), you can set the `allow_forks` input to `"false"` to prevent deployments from running on forks. 4. You should **always** (unless you have a certain restriction) use the `sha` output variable over the `ref` output variable when deploying. It is more reliable for deployments, and safer from a security perspective. More details about using commit SHAs for deployments can be found [here](./docs/deploying-commit-SHAs.md). +5. If your deployment workflow runs helper scripts or deployment message templates, consider using [trusted checkouts](docs/trusted-checkouts.md) so those helpers come from the protected default branch instead of the pull request checkout. ## Alternate Command Syntax diff --git a/docs/examples.md b/docs/examples.md index 195aa48c..6221ceaf 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -12,6 +12,7 @@ Quick links below to jump to a specific branch-deploy example: - [Table of Contents](#table-of-contents) - [Simple Example](#simple-example) - [Terraform](#terraform) + - [Terraform with Trusted Checkouts](#terraform-with-trusted-checkouts) - [Heroku](#heroku) - [Railway](#railway) - [SSH](#ssh) @@ -181,6 +182,408 @@ jobs: run: exit 1 ``` +## Terraform with Trusted Checkouts + +This example shows a hardened Terraform setup that separates trusted deployment +helper code from the working pull request code selected by branch-deploy. + +- `.noop` runs `terraform plan` from the exact working commit SHA selected by branch-deploy +- `.deploy` runs `terraform apply` from that same working commit SHA +- deployment helper scripts and deployment message templates run from the trusted default-branch checkout +- Terraform output is captured in `RUNNER_TEMP` and inserted into a trusted deployment message template +- merge deploy mode avoids redeploying a merge commit when the latest deployment already matches the default branch tree +- unlock-on-merge mode cleans up sticky locks after the pull request merges + +For the general security model behind this pattern, see the +[trusted checkout hardening guide](trusted-checkouts.md). + +### `.github/workflows/branch-deploy.yml` + +```yaml +name: branch-deploy + +on: + issue_comment: + types: [created] + +permissions: + pull-requests: write + deployments: write + contents: write + checks: read + statuses: read + +env: + TF_IN_AUTOMATION: "true" + +jobs: + branch-deploy: + if: + ${{ github.event.issue.pull_request && + (startsWith(github.event.comment.body, '.deploy') || + startsWith(github.event.comment.body, '.noop') || + startsWith(github.event.comment.body, '.lock') || + startsWith(github.event.comment.body, '.help') || + startsWith(github.event.comment.body, '.wcid') || + startsWith(github.event.comment.body, '.unlock')) }} + runs-on: ubuntu-latest + environment: + name: production + deployment: false + concurrency: + group: ${{ (startsWith(github.event.comment.body, '.deploy') || startsWith(github.event.comment.body, '.noop')) && 'terraform-production' || format('branch-deploy-support-{0}', github.run_id) }} + cancel-in-progress: false + queue: max + + steps: + - name: derive trusted checkout path + id: trusted-path + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + echo "DEFAULT_BRANCH=${DEFAULT_BRANCH}" + + trusted_dir="$(printf '%s' "${DEFAULT_BRANCH}" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//')" + + if [[ -z "${trusted_dir}" || "${trusted_dir}" == "." || "${trusted_dir}" == ".." ]]; then + echo "invalid trusted checkout directory derived from default branch: ${DEFAULT_BRANCH}" >&2 + exit 1 + fi + + if [[ ! "${trusted_dir}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "trusted checkout directory contains unsafe characters: ${trusted_dir}" >&2 + exit 1 + fi + + echo "trusted_dir=${trusted_dir}" >> "${GITHUB_OUTPUT}" + echo "trusted checkout directory: ${trusted_dir}" + + - name: branch-deploy + id: branch-deploy + uses: github/branch-deploy@vX.X.X + with: + trigger: ".deploy" + sticky_locks: "true" + deployment_confirmation: "true" + deploy_message_path: ${{ steps.trusted-path.outputs.trusted_dir }}/.github/deployment_message.md + allow_forks: "false" + + - name: derive working checkout path + id: working-path + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + env: + DEPLOY_SHA: ${{ steps.branch-deploy.outputs.sha }} + TRUSTED_DIR: ${{ steps.trusted-path.outputs.trusted_dir }} + run: | + set -euo pipefail + echo "DEPLOY_SHA=${DEPLOY_SHA} - TRUSTED_DIR=${TRUSTED_DIR}" + + if [[ ! "${DEPLOY_SHA}" =~ ^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$ ]]; then + echo "invalid branch-deploy sha output: ${DEPLOY_SHA}" >&2 + exit 1 + fi + + working_dir="deployment-${DEPLOY_SHA}" + + if [[ "${working_dir}" == "${TRUSTED_DIR}" ]]; then + echo "working checkout directory collides with trusted checkout directory: ${working_dir}" >&2 + exit 1 + fi + + echo "working_dir=${working_dir}" >> "${GITHUB_OUTPUT}" + echo "working checkout directory: ${working_dir}" + + - name: checkout trusted + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} # for issue_comment, github.sha is the last commit on the default branch + path: ${{ steps.trusted-path.outputs.trusted_dir }} + fetch-depth: 1 + persist-credentials: false + + - name: checkout working deployment sha + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + uses: actions/checkout@v6 + with: + ref: ${{ steps.branch-deploy.outputs.sha }} + path: ${{ steps.working-path.outputs.working_dir }} + fetch-depth: 1 + persist-credentials: false + + - name: verify checkout provenance + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + env: + TRUSTED_DIR: ${{ steps.trusted-path.outputs.trusted_dir }} + TRUSTED_SHA: ${{ github.sha }} + WORKING_DIR: ${{ steps.working-path.outputs.working_dir }} + WORKING_SHA: ${{ steps.branch-deploy.outputs.sha }} + run: | + set -euo pipefail + + trusted_head="$(git -C "${TRUSTED_DIR}" rev-parse HEAD)" + working_head="$(git -C "${WORKING_DIR}" rev-parse HEAD)" + + if [[ "${trusted_head}" != "${TRUSTED_SHA}" ]]; then + echo "trusted checkout HEAD mismatch: expected ${TRUSTED_SHA}, got ${trusted_head}" >&2 + exit 1 + fi + + if [[ "${working_head}" != "${WORKING_SHA}" ]]; then + echo "working checkout HEAD mismatch: expected ${WORKING_SHA}, got ${working_head}" >&2 + exit 1 + fi + + echo "trusted checkout: ${TRUSTED_DIR}@${trusted_head}" + echo "working checkout: ${WORKING_DIR}@${working_head}" + + - name: prepare terraform output path + id: terraform-output + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + run: | + set -euo pipefail + + output_dir="$(mktemp -d "${RUNNER_TEMP}/branch-deploy-output.XXXXXX")" + output_path="${output_dir}/terraform-output.txt" + : > "${output_path}" + + if [[ -L "${output_path}" || ! -f "${output_path}" ]]; then + echo "terraform output path is not a regular file: ${output_path}" >&2 + exit 1 + fi + + echo "path=${output_path}" >> "${GITHUB_OUTPUT}" + echo "terraform output path: ${output_path}" + + - name: read terraform version + id: terraform-version + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + working-directory: ${{ steps.working-path.outputs.working_dir }}/terraform + run: echo "version=$(cat .terraform-version)" >> "$GITHUB_OUTPUT" + + - name: setup terraform + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ steps.terraform-version.outputs.version }} + + - name: terraform init + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + working-directory: ${{ steps.working-path.outputs.working_dir }}/terraform + env: + TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} + run: terraform init + + - name: terraform plan + id: plan + if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }} + continue-on-error: true + working-directory: ${{ steps.working-path.outputs.working_dir }}/terraform + env: + TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} + run: | + set -o pipefail + terraform plan -no-color -compact-warnings | tee "${{ steps.terraform-output.outputs.path }}" + + - name: terraform apply + id: apply + if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop != 'true' }} + continue-on-error: true + working-directory: ${{ steps.working-path.outputs.working_dir }}/terraform + env: + TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} + run: | + set -o pipefail + terraform apply -auto-approve -no-color -compact-warnings | tee "${{ steps.terraform-output.outputs.path }}" + + - name: verify terraform output capture + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + env: + TERRAFORM_OUTPUT_PATH: ${{ steps.terraform-output.outputs.path }} + run: | + set -euo pipefail + + if [[ -L "${TERRAFORM_OUTPUT_PATH}" || ! -f "${TERRAFORM_OUTPUT_PATH}" ]]; then + echo "terraform output path is not a regular file: ${TERRAFORM_OUTPUT_PATH}" >&2 + exit 1 + fi + + - name: update deploy comment + if: ${{ steps.branch-deploy.outputs.continue == 'true' }} + working-directory: ${{ steps.trusted-path.outputs.trusted_dir }} + env: + RESULTS_PATH: ${{ steps.terraform-output.outputs.path }} + run: python3 script/ci/update_deploy_msg.py + + - name: check terraform plan + if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' && steps.plan.outcome == 'failure' }} + run: exit 1 + + - name: check terraform apply + if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop != 'true' && steps.apply.outcome == 'failure' }} + run: exit 1 +``` + +### `.github/workflows/deploy.yml` + +```yaml +name: deploy + +on: + push: + branches: ["main"] + +permissions: + contents: read + deployments: write + +jobs: + deployment-check: + runs-on: ubuntu-latest + outputs: + continue: ${{ steps.deployment-check.outputs.continue }} + sha: ${{ steps.deployment-check.outputs.sha }} + + steps: + - name: deployment check + id: deployment-check + uses: github/branch-deploy@vX.X.X + with: + merge_deploy_mode: "true" + environment: production + + deploy: + if: ${{ needs.deployment-check.outputs.continue == 'true' }} + needs: deployment-check + runs-on: ubuntu-latest + concurrency: + group: terraform-production + cancel-in-progress: false + queue: max + environment: production + defaults: + run: + working-directory: terraform/ + env: + TF_IN_AUTOMATION: "true" + + steps: + - name: checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.deployment-check.outputs.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: read terraform version + id: terraform-version + working-directory: . + run: echo "version=$(cat terraform/.terraform-version)" >> "$GITHUB_OUTPUT" + + - name: setup terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ steps.terraform-version.outputs.version }} + + - name: terraform init + env: + TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} + run: terraform init + + - name: terraform apply + env: + TF_TOKEN_app_terraform_io: ${{ secrets.TF_API_TOKEN }} + run: terraform apply -auto-approve -no-color -compact-warnings +``` + +### `.github/workflows/unlock-on-merge.yml` + +```yaml +name: Unlock On Merge + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + unlock-on-merge: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + + steps: + - name: unlock on merge + id: unlock-on-merge + uses: github/branch-deploy@vX.X.X + with: + unlock_on_merge_mode: "true" + environment_targets: production +``` + +### `.github/deployment_message.md` + +````markdown +### Deployment Results {{ ":white_check_mark:" if status === "success" else ":x:" }} + +{% if status === "success" %}**{{ actor }}** successfully **{{ "noop" if noop else "branch" }}** deployed branch `{{ ref }}` to **{{ environment }}**{% endif %}{% if status === "failure" %}**{{ actor }}** your **{{ "noop" if noop else "branch" }}** deployment of `{{ ref }}` failed to deploy to the **{{ environment }}** environment{% endif %} + +
Show Results + +```terraform +[[ results ]] +``` + +
+```` + +### `script/ci/update_deploy_msg.py` + +```python +from pathlib import Path +import os + + +TEMPLATE_FILE = Path(".github/deployment_message.md") +RESULTS_PLACEHOLDER = "[[ results ]]" +DEFAULT_RESULTS = "No deployment results were captured." + + +def read_results() -> str: + results_path = os.environ.get("RESULTS_PATH") + if results_path: + path = Path(results_path) + if path.exists(): + return path.read_text() + + return os.environ.get("MSG", DEFAULT_RESULTS) + + +def escape_nunjucks_opening_delimiters(results: str) -> str: + escaped = [] + for index, char in enumerate(results): + if char == "{" and index + 1 < len(results) and results[index + 1] in "{%#": + escaped.append("{ ") + else: + escaped.append(char) + + return "".join(escaped) + + +def main() -> None: + template = TEMPLATE_FILE.read_text() + results = escape_nunjucks_opening_delimiters(read_results().strip() or DEFAULT_RESULTS) + rendered = template.replace(RESULTS_PLACEHOLDER, results) + TEMPLATE_FILE.write_text(rendered) + print(rendered) + + +if __name__ == "__main__": + main() +``` + ## Heroku This example shows how you could use this Action with [Heroku](https://heroku.com) diff --git a/docs/trusted-checkouts.md b/docs/trusted-checkouts.md new file mode 100644 index 00000000..fb340aa7 --- /dev/null +++ b/docs/trusted-checkouts.md @@ -0,0 +1,207 @@ +# Trusted Checkouts + +`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. + +## When To Use This Pattern + +Use trusted checkouts when your branch-deploy workflow does both of these: + +1. checks out pull request content with `steps.branch-deploy.outputs.sha` +2. executes repository-owned helper code or reads templates after that checkout + +Common examples include: + +- `script/deploy`, `script/release`, or `script/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. + +## Recommended Shape + +A hardened workflow usually follows this sequence: + +1. Derive a trusted checkout path from the repository default branch. +2. Run `github/branch-deploy` from the default-branch workflow. +3. Validate `steps.branch-deploy.outputs.sha` as an exact commit SHA. +4. Derive a working checkout path from that SHA. +5. Check out trusted helper/template code at `github.sha`. +6. Check out working deployment code at `steps.branch-deploy.outputs.sha`. +7. Verify both checkout `HEAD` values before running deployment commands. +8. Run deployment commands from the working checkout. +9. 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. + +## Checkout Path Safety + +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. + +## Checkout Hygiene + +For both checkouts, prefer shallow checkouts and avoid persisting credentials: + +```yaml +with: + fetch-depth: 1 + persist-credentials: false +``` + +Use the exact `sha` output from branch-deploy for working code: + +```yaml +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](deploying-commit-SHAs.md) 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: + +```yaml +with: + ref: ${{ github.sha }} +``` + +## Custom Deployment Messages + +If you use `deploy_message_path`, point it at the trusted checkout: + +```yaml +with: + deploy_message_path: ${{ steps.trusted-path.outputs.trusted_dir }}/.github/deployment_message.md +``` + +That 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](custom-deployment-messages.md) for details on +deployment message rendering. + +## Deployment Output Files + +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: + +```yaml +- 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: + +```yaml +- 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.py +``` + +## Other Hardening Options + +Trusted 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 `sha` output for deployment checkouts. + +For Terraform or other tools with shared remote state, use GitHub Actions +concurrency to avoid state-lock races. For example: + +```yaml +concurrency: + group: terraform-production + cancel-in-progress: false + queue: max +``` + +Apply 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. + +## Full Terraform Example + +For a complete sanitized workflow set using this pattern with Terraform, see +[Terraform with Trusted Checkouts](examples.md#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: + +- [Deploying Commit SHAs](deploying-commit-SHAs.md) +- [Custom Deployment Messages](custom-deployment-messages.md) +- [Deployment Confirmation](deployment-confirmation.md) +- [Merge Commit Workflow Strategy](merge-commit-strategy.md) +- [Unlock On Merge Mode](unlock-on-merge.md)