From 7e2346611257d73195adde7e0fbbfdae8012ef32 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:47:11 +0000 Subject: [PATCH 1/2] feat(pages): add Cloudflare acceptance deploy and release-gated production Add an opt-in 'cloudflare-acceptance' deploy that publishes every production-branch commit to a stable Cloudflare branch (default 'acceptance'), and 'cloudflare-production-on-release' so production deploys only on GitHub release events instead of every main commit. Both default to false, so existing callers are unaffected. Acceptance reuses the production build path and requires Cloudflare secrets (same as production). --- .github/workflows/pages.yml | 115 +++++++++++++++++++++++++++++++++-- docs/architecture.md | 45 +++++++------- workflow-templates/pages.yml | 5 ++ 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 4ecfcb9..fc68368 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -82,6 +82,24 @@ on: required: false type: boolean default: false + cloudflare-production-on-release: + description: >- + Deploy Cloudflare production only on GitHub release events instead of + on every production-branch commit. The caller must also trigger the + workflow on the release event. + required: false + type: boolean + default: false + cloudflare-acceptance: + description: "Deploy an acceptance build to Cloudflare Pages on production-branch commits" + required: false + type: boolean + default: false + cloudflare-acceptance-branch: + description: "Cloudflare branch name used for acceptance deploys (gives a stable acceptance URL)" + required: false + type: string + default: "acceptance" cloudflare-project-name: description: "Cloudflare Pages project name; defaults to the repo name" required: false @@ -120,6 +138,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_PRODUCTION: ${{ inputs.cloudflare-production }} + CLOUDFLARE_ACCEPTANCE: ${{ inputs.cloudflare-acceptance }} CLOUDFLARE_PREVIEW: ${{ inputs.cloudflare-preview }} CONFIGURED_PROJECT_NAME: ${{ inputs.cloudflare-project-name }} REPOSITORY_NAME: ${{ github.event.repository.name }} @@ -128,7 +147,7 @@ jobs: project_name="${CONFIGURED_PROJECT_NAME:-$REPOSITORY_NAME}" - if [ "${CLOUDFLARE_PREVIEW}" != "true" ] && [ "${CLOUDFLARE_PRODUCTION}" != "true" ]; then + if [ "${CLOUDFLARE_PREVIEW}" != "true" ] && [ "${CLOUDFLARE_PRODUCTION}" != "true" ] && [ "${CLOUDFLARE_ACCEPTANCE}" != "true" ]; then echo "enabled=false" >>"${GITHUB_OUTPUT}" echo "Cloudflare Pages deployments are disabled by input." exit 0 @@ -136,8 +155,8 @@ jobs: if [ -z "${CLOUDFLARE_API_TOKEN}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID}" ]; then echo "enabled=false" >>"${GITHUB_OUTPUT}" - if [ "${CLOUDFLARE_PRODUCTION}" = "true" ]; then - echo "::error::cloudflare-production is enabled, but CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is missing." + if [ "${CLOUDFLARE_PRODUCTION}" = "true" ] || [ "${CLOUDFLARE_ACCEPTANCE}" = "true" ]; then + echo "::error::cloudflare-production or cloudflare-acceptance is enabled, but CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is missing." exit 1 fi @@ -286,9 +305,15 @@ jobs: - test if: >- inputs.cloudflare-production && - github.event_name != 'pull_request' && - github.ref == format('refs/heads/{0}', inputs.production-branch) && - needs.detect-cloudflare.outputs.enabled == 'true' + needs.detect-cloudflare.outputs.enabled == 'true' && + ( + (inputs.cloudflare-production-on-release && github.event_name == 'release') || + ( + !inputs.cloudflare-production-on-release && + github.event_name != 'pull_request' && + github.ref == format('refs/heads/{0}', inputs.production-branch) + ) + ) runs-on: ubuntu-24.04 timeout-minutes: 10 concurrency: @@ -357,6 +382,84 @@ jobs: --branch=${{ inputs.cloudflare-production-branch }} wranglerVersion: ${{ inputs.wrangler-version }} + deploy-cloudflare-acceptance: + name: Deploy Cloudflare acceptance + needs: + - detect-cloudflare + - test + if: >- + inputs.cloudflare-acceptance && + github.event_name != 'pull_request' && + github.ref == format('refs/heads/{0}', inputs.production-branch) && + needs.detect-cloudflare.outputs.enabled == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + concurrency: + group: cloudflare-pages-acceptance-${{ github.repository }} + cancel-in-progress: false + environment: + name: cloudflare-pages-acceptance + url: ${{ steps.deploy.outputs.deployment-url }} + permissions: + contents: read + deployments: write + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # Fetch tags so builds can read the release tag at HEAD + # (footer/version metadata). Safe when no tags exist. + fetch-tags: true + + - name: Setup Node.js + if: ${{ inputs.build-command != '' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.node-cache }} + + - name: Install dependencies + if: ${{ inputs.build-command != '' && inputs.install-command != '' }} + env: + INSTALL_COMMAND: ${{ inputs.install-command }} + run: bash -euo pipefail -c "${INSTALL_COMMAND}" + + - name: Build site + if: ${{ inputs.build-command != '' }} + env: + BUILD_COMMAND: ${{ inputs.build-command }} + run: bash -euo pipefail -c "${BUILD_COMMAND}" + + - name: Run pre-deploy command + if: ${{ inputs.pre-deploy-command != '' }} + env: + PRE_DEPLOY_COMMAND: ${{ inputs.pre-deploy-command }} + run: bash -euo pipefail -c "${PRE_DEPLOY_COMMAND}" + + - name: Ensure Cloudflare Pages project exists + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 + continue-on-error: true + with: + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + command: >- + pages project create ${{ needs.detect-cloudflare.outputs.project-name }} + --production-branch ${{ inputs.cloudflare-production-branch }} + wranglerVersion: ${{ inputs.wrangler-version }} + + - name: Deploy to Cloudflare Pages + id: deploy + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 + with: + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + command: >- + pages deploy ${{ inputs.artifact-path }} + --project-name=${{ needs.detect-cloudflare.outputs.project-name }} + --branch=${{ inputs.cloudflare-acceptance-branch }} + wranglerVersion: ${{ inputs.wrangler-version }} + deploy-preview: name: Deploy Cloudflare preview needs: diff --git a/docs/architecture.md b/docs/architecture.md index 98b7aec..4fa4eb7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,27 +136,30 @@ optionally deploys same-repository pull request previews to Cloudflare Pages. Cloudflare jobs detect missing Cloudflare secrets before any deploy work. Missing secrets fail production Cloudflare deploys and skip preview-only deploys. -| Input | Description | -| ------------------------------ | ------------------------------------------------------------------------------------- | -| `node-version` | **Required.** Node.js version to install. | -| `node-cache` | Package manager cache for test and production jobs. Default: empty (disabled). | -| `wrangler-version` | **Required.** Wrangler version to install for previews; inputs cannot be conditional. | -| `production-branch` | Branch that deploys to production. Default: `main`. | -| `artifact-path` | Directory uploaded to Pages. Default: `.`. | -| `install-command` | Dependency install command. Default: `npm ci`. | -| `test-command` | Validation command block. Default: empty. | -| `test-setup-command` | Optional command after install and before tests. | -| `build-command` | Optional build command before deployment. | -| `pre-deploy-command` | Optional production-only pre-upload command. | -| `pre-preview-command` | Optional preview-only pre-deploy command. | -| `update-sitemap-lastmod` | Update sitemap `` dates. Default: `false`. | -| `sitemap-path` | Sitemap file path. Default: `sitemap.xml`. | -| `github-pages` | Deploy production to GitHub Pages. Default: `true`. | -| `cloudflare-preview` | Enable Cloudflare pull request previews. Default: `true`. | -| `cloudflare-production` | Deploy production to Cloudflare Pages. Default: `false`. | -| `cloudflare-project-name` | Cloudflare Pages project; lowercase letters, numbers, and hyphens only. | -| `cloudflare-production-branch` | Cloudflare production branch. Default: `main`. | -| `preview-comment-marker` | Marker used to update the preview PR comment. | +| Input | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `node-version` | **Required.** Node.js version to install. | +| `node-cache` | Package manager cache for test and production jobs. Default: empty (disabled). | +| `wrangler-version` | **Required.** Wrangler version to install for previews; inputs cannot be conditional. | +| `production-branch` | Branch that deploys to production. Default: `main`. | +| `artifact-path` | Directory uploaded to Pages. Default: `.`. | +| `install-command` | Dependency install command. Default: `npm ci`. | +| `test-command` | Validation command block. Default: empty. | +| `test-setup-command` | Optional command after install and before tests. | +| `build-command` | Optional build command before deployment. | +| `pre-deploy-command` | Optional production-only pre-upload command. | +| `pre-preview-command` | Optional preview-only pre-deploy command. | +| `update-sitemap-lastmod` | Update sitemap `` dates. Default: `false`. | +| `sitemap-path` | Sitemap file path. Default: `sitemap.xml`. | +| `github-pages` | Deploy production to GitHub Pages. Default: `true`. | +| `cloudflare-preview` | Enable Cloudflare pull request previews. Default: `true`. | +| `cloudflare-production` | Deploy production to Cloudflare Pages. Default: `false`. | +| `cloudflare-production-on-release` | Deploy Cloudflare production only on `release` events, not every main commit. Default: `false`. | +| `cloudflare-acceptance` | Deploy an acceptance build to Cloudflare on main commits. Default: `false`. | +| `cloudflare-acceptance-branch` | Cloudflare branch name for acceptance deploys. Default: `acceptance`. | +| `cloudflare-project-name` | Cloudflare Pages project; lowercase letters, numbers, and hyphens only. | +| `cloudflare-production-branch` | Cloudflare production branch. Default: `main`. | +| `preview-comment-marker` | Marker used to update the preview PR comment. | **Example caller:** diff --git a/workflow-templates/pages.yml b/workflow-templates/pages.yml index db4d609..e38e126 100644 --- a/workflow-templates/pages.yml +++ b/workflow-templates/pages.yml @@ -48,6 +48,11 @@ jobs: # github-pages: false # cloudflare-production: true # cloudflare-project-name: "my-site" + # Optional: deploy every main commit to an acceptance environment and + # reserve production for releases. Also add the release trigger to `on:` + # above, e.g. `release: { types: [published] }`. + # cloudflare-acceptance: true + # cloudflare-production-on-release: true secrets: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} From a17ddd63285ed196adc195a9696dd38a40f635b8 Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:34:53 +0000 Subject: [PATCH 2/2] refactor(pages): unify Cloudflare prod/acc into one matrix deploy job Collapse the duplicated deploy-cloudflare-production and deploy-cloudflare-acceptance jobs into a single deploy-cloudflare job driven by a matrix that detect-cloudflare computes per event (only the targets that should actually deploy are included, so no empty/protected environment runs). Disable the package-manager cache on the deploy job to resolve the zizmor cache-poisoning alert on published artifacts. --- .github/workflows/pages.yml | 161 +++++++++++++++--------------------- 1 file changed, 65 insertions(+), 96 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fc68368..934fd30 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -131,6 +131,9 @@ jobs: outputs: enabled: ${{ steps.detect.outputs.enabled }} project-name: ${{ steps.detect.outputs.project-name }} + # JSON array of production-style deploy targets ({target, environment, + # branch}) that should run for this event. Empty when nothing deploys. + targets: ${{ steps.detect.outputs.targets }} steps: - name: Detect Cloudflare secrets id: detect @@ -138,8 +141,14 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_PRODUCTION: ${{ inputs.cloudflare-production }} + CLOUDFLARE_PRODUCTION_ON_RELEASE: ${{ inputs.cloudflare-production-on-release }} CLOUDFLARE_ACCEPTANCE: ${{ inputs.cloudflare-acceptance }} CLOUDFLARE_PREVIEW: ${{ inputs.cloudflare-preview }} + CLOUDFLARE_PRODUCTION_BRANCH: ${{ inputs.cloudflare-production-branch }} + CLOUDFLARE_ACCEPTANCE_BRANCH: ${{ inputs.cloudflare-acceptance-branch }} + PRODUCTION_BRANCH: ${{ inputs.production-branch }} + EVENT_NAME: ${{ github.event_name }} + GIT_REF: ${{ github.ref }} CONFIGURED_PROJECT_NAME: ${{ inputs.cloudflare-project-name }} REPOSITORY_NAME: ${{ github.event.repository.name }} run: |- @@ -149,12 +158,14 @@ jobs: if [ "${CLOUDFLARE_PREVIEW}" != "true" ] && [ "${CLOUDFLARE_PRODUCTION}" != "true" ] && [ "${CLOUDFLARE_ACCEPTANCE}" != "true" ]; then echo "enabled=false" >>"${GITHUB_OUTPUT}" + echo "targets=[]" >>"${GITHUB_OUTPUT}" echo "Cloudflare Pages deployments are disabled by input." exit 0 fi if [ -z "${CLOUDFLARE_API_TOKEN}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID}" ]; then echo "enabled=false" >>"${GITHUB_OUTPUT}" + echo "targets=[]" >>"${GITHUB_OUTPUT}" if [ "${CLOUDFLARE_PRODUCTION}" = "true" ] || [ "${CLOUDFLARE_ACCEPTANCE}" = "true" ]; then echo "::error::cloudflare-production or cloudflare-acceptance is enabled, but CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is missing." exit 1 @@ -174,9 +185,45 @@ jobs: exit 1 fi - echo "project-name=${project_name}" >>"${GITHUB_OUTPUT}" - echo "enabled=true" >>"${GITHUB_OUTPUT}" - echo "Cloudflare Pages deployments are configured." + # Work out which production-style targets deploy for this event. + # Pull request previews are handled by a separate job. + is_production_branch="false" + if [ "${EVENT_NAME}" != "pull_request" ] && [ "${GIT_REF}" = "refs/heads/${PRODUCTION_BRANCH}" ]; then + is_production_branch="true" + fi + + deploy_production="false" + if [ "${CLOUDFLARE_PRODUCTION}" = "true" ]; then + if [ "${CLOUDFLARE_PRODUCTION_ON_RELEASE}" = "true" ]; then + [ "${EVENT_NAME}" = "release" ] && deploy_production="true" + else + [ "${is_production_branch}" = "true" ] && deploy_production="true" + fi + fi + + deploy_acceptance="false" + if [ "${CLOUDFLARE_ACCEPTANCE}" = "true" ] && [ "${is_production_branch}" = "true" ]; then + deploy_acceptance="true" + fi + + targets="$( + jq -cn \ + --arg prod_branch "${CLOUDFLARE_PRODUCTION_BRANCH}" \ + --arg acc_branch "${CLOUDFLARE_ACCEPTANCE_BRANCH}" \ + --argjson prod "${deploy_production}" \ + --argjson acc "${deploy_acceptance}" \ + '[ + (if $prod then {target: "production", environment: "cloudflare-pages", branch: $prod_branch} else empty end), + (if $acc then {target: "acceptance", environment: "cloudflare-pages-acceptance", branch: $acc_branch} else empty end) + ]' + )" + + { + echo "project-name=${project_name}" + echo "enabled=true" + echo "targets=${targets}" + } >>"${GITHUB_OUTPUT}" + echo "Cloudflare Pages deployments are configured (targets: ${targets})." test: name: Run tests @@ -298,29 +345,27 @@ jobs: id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 - deploy-cloudflare-production: - name: Deploy Cloudflare production + deploy-cloudflare: + name: Deploy Cloudflare ${{ matrix.target }} needs: - detect-cloudflare - test if: >- - inputs.cloudflare-production && needs.detect-cloudflare.outputs.enabled == 'true' && - ( - (inputs.cloudflare-production-on-release && github.event_name == 'release') || - ( - !inputs.cloudflare-production-on-release && - github.event_name != 'pull_request' && - github.ref == format('refs/heads/{0}', inputs.production-branch) - ) - ) + needs.detect-cloudflare.outputs.targets != '[]' + strategy: + fail-fast: false + # Production and acceptance share the same build and deploy steps; the + # detect-cloudflare job decides which targets run for this event. + matrix: + include: ${{ fromJSON(needs.detect-cloudflare.outputs.targets) }} runs-on: ubuntu-24.04 timeout-minutes: 10 concurrency: - group: cloudflare-pages-${{ github.repository }} + group: cloudflare-pages-${{ matrix.target }}-${{ github.repository }} cancel-in-progress: false environment: - name: cloudflare-pages + name: ${{ matrix.environment }} url: ${{ steps.deploy.outputs.deployment-url }} permissions: contents: read @@ -339,85 +384,9 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ inputs.node-version }} - cache: ${{ inputs.node-cache }} - - - name: Install dependencies - if: ${{ inputs.build-command != '' && inputs.install-command != '' }} - env: - INSTALL_COMMAND: ${{ inputs.install-command }} - run: bash -euo pipefail -c "${INSTALL_COMMAND}" - - - name: Build site - if: ${{ inputs.build-command != '' }} - env: - BUILD_COMMAND: ${{ inputs.build-command }} - run: bash -euo pipefail -c "${BUILD_COMMAND}" - - - name: Run pre-deploy command - if: ${{ inputs.pre-deploy-command != '' }} - env: - PRE_DEPLOY_COMMAND: ${{ inputs.pre-deploy-command }} - run: bash -euo pipefail -c "${PRE_DEPLOY_COMMAND}" - - - name: Ensure Cloudflare Pages project exists - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - continue-on-error: true - with: - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - command: >- - pages project create ${{ needs.detect-cloudflare.outputs.project-name }} - --production-branch ${{ inputs.cloudflare-production-branch }} - wranglerVersion: ${{ inputs.wrangler-version }} - - - name: Deploy to Cloudflare Pages - id: deploy - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - with: - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - command: >- - pages deploy ${{ inputs.artifact-path }} - --project-name=${{ needs.detect-cloudflare.outputs.project-name }} - --branch=${{ inputs.cloudflare-production-branch }} - wranglerVersion: ${{ inputs.wrangler-version }} - - deploy-cloudflare-acceptance: - name: Deploy Cloudflare acceptance - needs: - - detect-cloudflare - - test - if: >- - inputs.cloudflare-acceptance && - github.event_name != 'pull_request' && - github.ref == format('refs/heads/{0}', inputs.production-branch) && - needs.detect-cloudflare.outputs.enabled == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - concurrency: - group: cloudflare-pages-acceptance-${{ github.repository }} - cancel-in-progress: false - environment: - name: cloudflare-pages-acceptance - url: ${{ steps.deploy.outputs.deployment-url }} - permissions: - contents: read - deployments: write - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - # Fetch tags so builds can read the release tag at HEAD - # (footer/version metadata). Safe when no tags exist. - fetch-tags: true - - - name: Setup Node.js - if: ${{ inputs.build-command != '' }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.node-cache }} + # Disable the package-manager cache on deploy jobs so a poisoned + # cache cannot influence the published artifact (zizmor audit). + package-manager-cache: false - name: Install dependencies if: ${{ inputs.build-command != '' && inputs.install-command != '' }} @@ -457,7 +426,7 @@ jobs: command: >- pages deploy ${{ inputs.artifact-path }} --project-name=${{ needs.detect-cloudflare.outputs.project-name }} - --branch=${{ inputs.cloudflare-acceptance-branch }} + --branch=${{ matrix.branch }} wranglerVersion: ${{ inputs.wrangler-version }} deploy-preview: