From 1d62265f532420ccd0a107784ce4cb5334f2b0fa Mon Sep 17 00:00:00 2001 From: Brennan Kinney <5098581+polarathene@users.noreply.github.com> Date: Fri, 15 Nov 2024 13:36:47 +1300 Subject: [PATCH] ci(docs-preview): Refactor to `pull_request_target` --- .github/workflows/docs-preview-deploy.yml | 111 ++++----------------- .github/workflows/docs-preview-prepare.yml | 30 +++--- .github/workflows/docs-preview.yml | 89 +++++++++++++++++ 3 files changed, 121 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/docs-preview.yml diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index 40b5cbd2..d430529f 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -1,100 +1,45 @@ -name: 'Documentation (run)' +name: 'Docs Preview (Deploy)' on: - # This workflow runs off the primary branch which provides access to the `secrets` context: - workflow_run: - workflows: ['Documentation (PR)'] - types: - - completed + workflow_call: + inputs: + preview-context: + description: 'Preview Metadata (JSON)' + required: true + type: string + secrets: + NETLIFY_AUTH_TOKEN: + required: true + NETLIFY_SITE_ID: + required: true + +env: + BUILD_DIR: ${{ fromJSON( inputs.preview-context ).build_dir }} + # PR head SHA (latest commit): + PR_HEADSHA: ${{ fromJSON( inputs.preview-context ).pull_request.head_sha }} + PR_NUMBER: ${{ fromJSON( inputs.preview-context ).pull_request.number }} + # Deploy URL preview prefix (the site name for the prefix is managed at Netlify): + PREVIEW_SITE_PREFIX: ${{ fromJSON( inputs.preview-context ).netlify.deploy_prefix }} permissions: - # Required by `actions/download-artifact`: - actions: read # Required by `marocchino/sticky-pull-request-comment`: pull-requests: write - # Required by `myrotvorets/set-commit-status-action`: - statuses: write jobs: - # This could have been another step in the `deploy-preview` job and used `GITHUB_ENV` instead of `GITHUB_OUTPUT`. - # It was split out into a separate job for a cleaner overview of `deploy-preview` ENV inputs and to minimize noise - # from that job related to this workaround (_that is incompatible with PRs from forks_). - pr-context: - name: 'Restore PR Context' - runs-on: ubuntu-24.04 - outputs: - PR_HEADSHA: ${{ steps.set-pr-context.outputs.PR_HEADSHA }} - PR_NUMBER: ${{ steps.set-pr-context.outputs.PR_NUMBER }} - # Requires a PR event triggered `docs-preview-prepare.yml` workflow run that was successful + ensure the head SHA belongs to an associated PR: - # NOTE: - # - The `contains` condition checks for event context that is not available when the PR is from a fork. An alternative method would be needed: - # https://stackoverflow.com/questions/59077079/how-to-get-pull-request-number-within-github-actions-workflow/79017997#79017997 - # - A multi-line `if` GHA expression must avoid wrapping with `${{ }}`, otherwise it is unintentionally parsed as a string: - # https://github.com/nikitastupin/pwnhub/blob/main/writings/if-condition.md - if: | - github.event.workflow_run.conclusion == 'success' - && github.event.workflow_run.event == 'pull_request' - && contains(github.event.workflow_run.pull_requests.*.head.sha, github.event.workflow_run.head_sha) - steps: - # NOTE: - # - The `workflow_run` metadata contains an array of `pull_requests`: - # 1. Take the `workflow_run` equivalent of `github.event.pull_request.number`. - # 2. There should only be one PR item in the array, verify that it shares the same `head_sha` (latest commit of PR). - # - Careful when using GHA context expressions that may have untrusted input here. The expressions are evaluated before the script content itself is run: - # https://github.com/docker-mailserver/docker-mailserver/pull/4247#discussion_r1827067475 - - name: 'Get PR number' - id: set-pr-context - env: - head_sha: ${{ github.event.workflow_run.head_sha }} - pull_requests: ${{ tojson(github.event.workflow_run.pull_requests) }} - run: | - PR_NUMBER=$(jq -r '[.[] | select(.head.sha == "${{ env.head_sha }}")][0].number' <<< "${pull_requests}") - { - echo 'PR_HEADSHA=${{ env.head_sha }}' - echo "PR_NUMBER=${PR_NUMBER}" - } >> "${GITHUB_OUTPUT}" - deploy-preview: name: 'Deploy Preview' runs-on: ubuntu-24.04 - needs: [pr-context] - env: - # NOTE: Keep this in sync with the equivalent ENV in `docs-preview-prepare.yml`: - BUILD_DIR: docs/site/ - # PR head SHA (latest commit): - PR_HEADSHA: ${{ needs.pr-context.outputs.PR_HEADSHA }} - PR_NUMBER: ${{ needs.pr-context.outputs.PR_NUMBER }} - # Deploy URL preview prefix (the site name for this prefix is managed at Netlify): - PREVIEW_SITE_PREFIX: pullrequest-${{ needs.pr-context.outputs.PR_NUMBER }} steps: - name: 'Retrieve and extract the built docs preview' uses: actions/download-artifact@v4 with: name: preview-build path: ${{ env.BUILD_DIR }} - # These are needed due this approach relying on `workflow_run`, so that it can access the build artifact: - # (uploaded from the associated `docs-preview-prepare.yml` workflow run) - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} # ==================== # # Deploy preview build # # ==================== # - # Manage workflow deployment status (Part 1/2): - # NOTE: - # - `workflow_run` trigger does not appear on the PR/commit checks status, only the initial prepare workflow triggered. - # This adds our own status check for this 2nd half of the workflow starting as `pending`, followed by `success` / `failure` at the end. - # - `enable-commit-status` from `nwtgck/actions-netlify` would have handled this, - # but the context `github.sha` that action tries to use references the primary branch commit that this workflow runs from, not the relevant PR commit. - - name: 'Commit Status (1/2) - Set Workflow Status as Pending' - uses: myrotvorets/set-commit-status-action@v2.0.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - status: pending - sha: ${{ env.PR_HEADSHA }} - context: 'Deploy Preview (pull_request => workflow_run)' - - name: 'Send preview build to Netlify' uses: nwtgck/actions-netlify@v3.0 id: preview-netlify @@ -152,19 +97,3 @@ jobs: [Documentation preview for this PR](${{ steps.preview-netlify.outputs.deploy-url }}) is ready! :tada: Built with commit: ${{ env.PR_HEADSHA }} - - # Manage workflow deployment status (Part 2/2): - - name: 'Commit Status (2/2) - Update deployment status' - uses: myrotvorets/set-commit-status-action@v2.0.1 - # Always run this step regardless of the job failing early: - if: ${{ always() }} - # Custom status descriptions: - env: - DEPLOY_SUCCESS: Successfully deployed preview. - DEPLOY_FAILURE: Failed to deploy preview. - with: - token: ${{ secrets.GITHUB_TOKEN }} - status: ${{ job.status == 'success' && 'success' || 'failure' }} - sha: ${{ github.event.workflow_run.head_sha }} - context: 'Deploy Preview (pull_request => workflow_run)' - description: ${{ job.status == 'success' && env.DEPLOY_SUCCESS || env.DEPLOY_FAILURE }} diff --git a/.github/workflows/docs-preview-prepare.yml b/.github/workflows/docs-preview-prepare.yml index 40b586e0..608aec46 100644 --- a/.github/workflows/docs-preview-prepare.yml +++ b/.github/workflows/docs-preview-prepare.yml @@ -1,28 +1,20 @@ -name: 'Documentation (PR)' +name: 'Docs Preview (Build)' on: - pull_request: - paths: - - 'docs/**' - - '.github/workflows/scripts/docs/build-docs.sh' - - '.github/workflows/docs-preview-prepare.yml' - -# If this workflow is triggered while already running for the PR, cancel any earlier running instances: -# Instances of the 2nd phase of this workflow (via `workflow_run`) lack any concurrency limits due to added complexity. -concurrency: - group: deploypreview-pullrequest-${{ github.event.pull_request.number }} - cancel-in-progress: true + workflow_call: + inputs: + preview-context: + description: 'Preview Metadata (JSON)' + required: true + type: string env: # Build output directory (created by the mkdocs-material container, keep this in sync with `build-docs.sh`): - BUILD_DIR: docs/site/ + BUILD_DIR: ${{ fromJSON( inputs.preview-context ).build_dir }} # These two are only needed to construct `PREVIEW_URL`: - PREVIEW_SITE_NAME: dms-doc-previews - PREVIEW_SITE_PREFIX: pullrequest-${{ github.event.pull_request.number }} + PREVIEW_SITE_NAME: ${{ fromJSON( inputs.preview-context ).netlify.site_name }} + PREVIEW_SITE_PREFIX: ${{ fromJSON( inputs.preview-context ).netlify.deploy_prefix }} -# `pull_request` workflow is unreliable alone: Non-collaborator contributions lack access to secrets for security reasons. -# A separate workflow (docs-preview-deploy.yml) handles the deploy after the potentially untrusted code is first run in this workflow. -# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ permissions: # Required by `actions/checkout` for git checkout: contents: read @@ -38,6 +30,8 @@ jobs: # Build docs preview # # ================== # + # CAUTION: Security risk due to executing `build-docs.sh` (which a PR could modify to be malicious): + # Care must be taken to run this workflow as if it were in an untrusted context (like the `on: pull_request` event trigger would). - name: 'Build with mkdocs-material via Docker' working-directory: docs/ env: diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 00000000..1e1feccd --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,89 @@ +name: 'Documentation (Preview)' + +# INFO: +# This workflow automates generating previews of our docs for PRs. +# For security reasons, it is necessary to split the workflow into two separate jobs to manage trust safely. + +# MAINTAINERS: +# - This set of `pull_request_target` + `workflow_call` workflows replaces the prior `pull_request` (untrusted) + `workflow_run` (trusted) workflows approach +# due to the need for acquiring PR metadata (Head SHA + PR number), which the `workflow_run` event context cannot provide when PRs are from forks. +# - Please be mindful of the risks when maintaining this workflow: +# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + +on: + pull_request_target: + paths: + - 'docs/**' + - '.github/workflows/scripts/docs/build-docs.sh' + +# If this workflow is triggered while an earlier instance is already running for the PR, +# cancel that instance in favor of this newly triggered run: +concurrency: + group: deploypreview-pullrequest-${{ github.event.pull_request.number }} + cancel-in-progress: true + +# Common inputs shared between the two workflow jobs (`preview` + `deploy`). +# Composed as JSON to pass as a single input which each called job will map into separate ENV for use. +env: + PREVIEW_CONTEXT: | + { + "build_dir": "docs/site/", + "netlify": { + "site_name": "dms-doc-previews", + "deploy_prefix": "pullrequest-${{ github.event.pull_request.number }}" + }, + "pull_request": { + "head_repo": "${{ github.event.pull_request.head.repo.full_name }}", + "head_sha": "${{ github.event.pull_request.head.sha }}", + "number": "${{ github.event.pull_request.number }}" + } + } + +# Grant `secrets.GITHUB_TOKEN` only the minimum permissions needed by actions to function: +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +# NOTE: See the associated `preview` and `deploy` workflows called for when these permissions are needed. +permissions: + contents: read + pull-requests: write + +# NOTE: Reusable workflows (`workflow_call`) default to empty secrets. +# Avoid using `secrets: inherit` in favor of passing only the required secrets to the job. +jobs: + # WORKAROUND: + # The alternative is to copy/paste the JSON value to the `preview-context` input of each job. + # + # PROBLEM: + # - `.with` restricts available contexts to only `github` and `needs`, it's not possible to use `env` context: + # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idwithinput_id + # - Likewise, workflows presently lack support for YAML anchors: + # https://github.com/actions/runner/issues/1182 + create-context: + name: 'Create Context' + runs-on: ubuntu-24.04 + outputs: + preview-context: ${{ steps.set-preview-context.outputs.preview-context }} + steps: + - id: set-preview-context + # The output value must be a single-line; use `jq` to convert the JSON to minified: + # NOTE: YAML `>-` does not help fold this multi-line content due to use of indentation: + # https://github.com/orgs/community/discussions/26105#discussioncomment-3250413 + run: echo "preview-context=$(jq --compact-output <<< "${PREVIEW_CONTEXT}")" >> "${GITHUB_OUTPUT}" + + # The `prepare` job is for running steps in an untrusted context (necessary to build the docs): + # CAUTION: This runs a build script which the PR could modify for malicious purposes. + prepare: + needs: [create-context] + uses: docker-mailserver/docker-mailserver/.github/workflows/docs-preview-prepare.yml@main + with: + preview-context: ${{ needs.create-context.outputs.preview-context }} + + # The `deploy` job is for running the remaining steps in a trusted context after building the PR branch: + # CAUTION: Do not execute any content from untrusted sources (the PR branch or the retrieved artifact from the `prepare` job) + deploy: + needs: [create-context, prepare] + uses: docker-mailserver/docker-mailserver/.github/workflows/docs-preview-deploy.yml@main + secrets: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + with: + preview-context: ${{ needs.create-context.outputs.preview-context }}