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
138 changes: 123 additions & 15 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
#
# Any product repo in the org can call this to publish a static site to
# Cloudflare Pages and (optionally) attach a custom domain, using the org
# secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. The token stays inside
# GitHub Actions — callers pass `secrets: inherit`.
# secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. This workflow references
# only these two declared values. Approved CWL callers map them explicitly.
#
# Example caller (.github/workflows/site.yml in a product repo):
#
Expand All @@ -18,7 +18,9 @@
# project_name: keyverse-marketing # Cloudflare Pages project (snake/kebab ok)
# build_dir: ./public # directory of built static assets
# custom_domain: keyverse.io # optional; must have a CF zone first
# secrets: inherit
# secrets:
# CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
#
name: Deploy Cloudflare Pages

Expand All @@ -38,13 +40,20 @@ on:
required: false
type: string
default: ""
secrets:
CLOUDFLARE_API_TOKEN:
description: "Cloudflare API token scoped to Pages deployment"
required: true
CLOUDFLARE_ACCOUNT_ID:
description: "Cloudflare account identifier that owns the Pages project"
required: true
Comment thread
seonghobae marked this conversation as resolved.

permissions:
contents: read

jobs:
deploy_pages:
name: Deploy ${{ inputs.project_name }}
name: Deploy Cloudflare Pages
runs-on: ubuntu-latest
steps:
- name: Checkout caller repo
Expand All @@ -57,25 +66,115 @@ jobs:
run: |
set -euo pipefail
if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then
echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not available. Caller must use 'secrets: inherit'."
echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not available. Caller must map both declared reusable-workflow secrets."
exit 1
fi

- name: Validate deployment inputs
id: deploy_inputs
env:
RAW_PROJECT_NAME: ${{ inputs.project_name }}
RAW_BUILD_DIR: ${{ inputs.build_dir }}
RAW_CUSTOM_DOMAIN: ${{ inputs.custom_domain }}
run: |
set -euo pipefail
python3 - <<'PYTHON'
from __future__ import annotations

import os
import re
import sys
from pathlib import Path

SAFE_PROJECT = re.compile(
r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\Z"
)
SAFE_PATH = re.compile(r"[A-Za-z0-9._/-]+\Z")
SAFE_LABEL = re.compile(
r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\Z"
)


def invalid() -> None:
"""Fail closed without reflecting an untrusted input value."""
print(
"::error::Invalid Cloudflare Pages deployment input.",
file=sys.stderr,
)
raise SystemExit(1)


project_name = os.environ.get("RAW_PROJECT_NAME", "")
if (
not 1 <= len(project_name) <= 58
or SAFE_PROJECT.fullmatch(project_name) is None
):
invalid()

raw_build_dir = os.environ.get("RAW_BUILD_DIR", "")
if raw_build_dir.startswith("./"):
build_dir = raw_build_dir[2:]
else:
build_dir = raw_build_dir
if (
not 1 <= len(build_dir) <= 512
or build_dir.startswith("-")
or SAFE_PATH.fullmatch(build_dir) is None
):
invalid()
path_parts = build_dir.split("/")
if any(part in {"", ".", ".."} for part in path_parts):
invalid()
Comment thread
seonghobae marked this conversation as resolved.

workspace_raw = os.environ.get("GITHUB_WORKSPACE", "")
if not workspace_raw:
invalid()
try:
workspace = Path(workspace_raw).resolve(strict=True)
build_path = (workspace / build_dir).resolve(strict=True)
build_path.relative_to(workspace)
except (OSError, RuntimeError, ValueError):
invalid()
if not build_path.is_dir():
invalid()
Comment thread
seonghobae marked this conversation as resolved.

custom_domain = os.environ.get("RAW_CUSTOM_DOMAIN", "")
if custom_domain:
if not 1 <= len(custom_domain) <= 253:
invalid()
labels = custom_domain.split(".")
if len(labels) < 2 or any(
not 1 <= len(label) <= 63
or SAFE_LABEL.fullmatch(label) is None
for label in labels
):
invalid()
custom_domain = custom_domain.lower()

output_path = os.environ.get("GITHUB_OUTPUT", "")
if not output_path:
invalid()
with Path(output_path).open("a", encoding="utf-8") as handle:
handle.write(f"project_name={project_name}\n")
handle.write(f"build_dir={build_dir}\n")
handle.write(f"custom_domain={custom_domain}\n")
PYTHON

- name: Deploy to Cloudflare Pages (wrangler)
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Creates the project on first run; publishes the build_dir to production.
command: pages deploy ${{ inputs.build_dir }} --project-name=${{ inputs.project_name }}
# Creates the project on first run; publishes the validated build_dir to production.
command: pages deploy ${{ steps.deploy_inputs.outputs.build_dir }} --project-name=${{ steps.deploy_inputs.outputs.project_name }}

- name: Attach custom domain (idempotent)
if: ${{ inputs.custom_domain != '' }}
if: ${{ steps.deploy_inputs.outputs.custom_domain != '' }}
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT_NAME: ${{ inputs.project_name }}
CUSTOM_DOMAIN: ${{ inputs.custom_domain }}
PROJECT_NAME: ${{ steps.deploy_inputs.outputs.project_name }}
CUSTOM_DOMAIN: ${{ steps.deploy_inputs.outputs.custom_domain }}
run: |
set -euo pipefail
api="https://api.cloudflare.com/client/v4"
Expand All @@ -102,11 +201,20 @@ jobs:

- name: Summary
if: always()
env:
PROJECT_NAME: ${{ steps.deploy_inputs.outputs.project_name }}
BUILD_DIR: ${{ steps.deploy_inputs.outputs.build_dir }}
CUSTOM_DOMAIN: ${{ steps.deploy_inputs.outputs.custom_domain }}
run: |
set -euo pipefail
custom_domain="${CUSTOM_DOMAIN}"
if [ -z "${custom_domain}" ]; then
custom_domain="(none)"
fi
# shellcheck disable=SC2016 # Markdown backticks are literal; values use printf arguments.
{
echo "## Cloudflare Pages deploy"
echo ""
echo "- **Project:** \`${{ inputs.project_name }}\`"
echo "- **Build dir:** \`${{ inputs.build_dir }}\`"
echo "- **Custom domain:** \`${{ inputs.custom_domain || '(none)' }}\`"
printf '## Cloudflare Pages deploy\n\n'
printf -- '- **Project:** `%s`\n' "${PROJECT_NAME}"
printf -- '- **Build dir:** `%s`\n' "${BUILD_DIR}"
printf -- '- **Custom domain:** `%s`\n' "${custom_domain}"
} >> "$GITHUB_STEP_SUMMARY"
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Semantic Versioning where the repository publishes a release.

### Fixed

- Replaced blanket inherited-secret guidance at the reusable Cloudflare Pages deployment boundary with an explicit two-secret, required caller contract, and fail-closed validated caller-controlled project, path, and domain inputs before Wrangler or Cloudflare API use.

- Publish only the sanitized cumulative Strix report tree, avoiding a later
copy of relative scanner output that could reintroduce known internal warning
text into uploaded security evidence.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ jobs:
with:
project_name: example-marketing
build_dir: ./public
secrets: inherit
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

5. If a repository cannot inherit the ruleset (for example a public fork
Expand Down
123 changes: 123 additions & 0 deletions docs/doctoring/deploy-pages-secret-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Cloudflare Pages reusable-workflow secret and input contract

## Decision

The reusable Pages deployment declares and references exactly two required
secret names for compliant callers: `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`. Approved CWL callers MUST map them explicitly and
MUST NOT use `secrets: inherit`:

```yaml
jobs:
deploy:
uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main
with:
project_name: example-marketing
build_dir: ./public
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

This is an explicit named interface and caller policy, not a GitHub runtime
allowlist. GitHub allows a same-organization or same-enterprise caller to use
`secrets: inherit`; secrets inherited that way can be referenced by the called
workflow even when they are not declared under `on.workflow_call.secrets`.
The declaration documents and validates explicit named mappings but cannot
disable GitHub's inheritance keyword. The called workflow itself references
only the two Cloudflare names above.

The workflow keeps `contents: read`, checks out the caller repository, and uses
the token only for Pages deployment and optional domain attachment. For an
approved explicit-mapping caller, GitHub rejects an invocation that omits either
required name before the job starts; the runtime guard remains a value-free
defense-in-depth check.

## Untrusted deployment inputs

Reusable-workflow string inputs are caller-controlled data. They are not
command, filesystem, Cloudflare-resource, or URL authority merely because the
caller is allowed to invoke the workflow. The workflow therefore validates the
three deployment inputs before any of them reaches Wrangler, a Cloudflare API
URL, or a shell-rendered summary.

`project_name` follows Cloudflare Pages' service boundary: one to 58 lowercase
alphanumeric/hyphen characters with an alphanumeric first and last character.
`build_dir` is bounded to a relative POSIX-style path, may not contain
option-like, traversal, whitespace, control, or backslash syntax, must resolve
to an existing directory, and must remain inside the exact checked-out
`GITHUB_WORKSPACE` after symlink resolution.
Every path component must be non-empty; callers should pass `./public`, not
`./public/`.
`custom_domain` is optional; when present it is bounded to DNS-style labels,
rejects path/query/fragment/port-like syntax, and is canonicalized to lowercase.
Invalid input fails with one generic value-free error rather than reflecting the
untrusted value.

Only the validator's sealed step outputs reach the Wrangler command, custom
domain API path, and job summary. The job display name is static so a raw
caller-supplied project name is not promoted into workflow presentation before
validation. The summary passes validated outputs through environment variables
rather than embedding raw GitHub expression text into the shell program.

Cloudflare's current Direct Upload documentation defines Pages deployment as
uploading one prebuilt asset directory with `wrangler pages deploy`, and its CI
guide uses the directory plus `--project-name=<PROJECT_NAME>`. This workflow
keeps exactly that product boundary while adding a stricter central validation
layer before argument construction. The validator is intentionally more
restrictive than accepting arbitrary strings: callers requiring a genuinely new
identifier/path shape must change the reviewed contract and its negative tests,
not bypass validation locally.

## Migration and acceptance

As of 2026-08-24, a live organization code search found no product workflow
that calls this reusable workflow; only the central workflow and its README
examples referenced `deploy-pages.yml@...`. The root README, infrastructure
guide, workflow example, and this record now use the same explicit mapping, and
the permanent contract test scans every fenced YAML example. Re-run the
organization search before merge. Any consumer found later must add the two
explicit mappings in its thin caller under that repository's writer lease.
Treat any `secrets: inherit` caller as a leaf migration defect, not a reason to
broaden the central interface.

Acceptance requires workflow contract tests, syntax and supply-chain checks,
and realistic positive/negative input tests that execute the production
validator itself. At minimum the tests cover a normal project/build/domain,
argument-like project names, absolute/traversing/build-option paths, malformed
hostnames, and a symlink escaping the checkout. A protected-main caller canary
must prove that validated inputs reach Wrangler with both required secret
mappings. A missing-mapping negative control must stop before deployment and
must not print a credential.

## Failure and rollback

If a consumer cannot migrate immediately, pin it to the last reviewed workflow
revision while its caller is repaired. Do not broaden the new interface,
reintroduce blanket inheritance, or interpolate raw inputs as a compatibility
shortcut. Roll back the central contract only for a confirmed GitHub reusable-
workflow or Cloudflare platform defect, and preserve the explicit two-name
secret interface plus fail-closed input validation in the replacement
transport.

If validation rejects a previously accepted caller, first determine whether the
caller relied on a genuinely supported Cloudflare identifier/path shape or on
ambiguous input that should never have crossed the command/URL boundary. Extend
the validator only with a focused RED/GREEN contract and keep symlink escape,
traversal, option injection, and value-free diagnostics intact.

## APA 7th references

Cloudflare. (2026a, April 21). *Direct Upload*. Cloudflare Pages documentation.
https://developers.cloudflare.com/pages/get-started/direct-upload/

Cloudflare. (2026b, April 21). *Use Direct Upload with continuous integration*.
Cloudflare Pages documentation.
https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/

GitHub. (n.d.). *Reuse workflows*. GitHub Docs. Retrieved August 24, 2026, from
https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows

IgorMinar. (2023, May 16). *C3 project name input is not correctly validated*
[GitHub issue]. Cloudflare Workers SDK.
https://github.com/cloudflare/workers-sdk/issues/3222
11 changes: 9 additions & 2 deletions infra/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ deleted unless you explicitly set `prune = true`.

## Deploying a product's static site to Cloudflare Pages

Product repos call the reusable workflow and inherit the org secrets:
Product repos call the reusable workflow and explicitly map the two declared
Cloudflare secret names:

```yaml
# .github/workflows/site.yml in e.g. cwl-idp (Keyverse)
Expand All @@ -91,9 +92,15 @@ jobs:
project_name: keyverse-marketing
build_dir: ./public
custom_domain: keyverse.io # optional; the CF zone must already exist
secrets: inherit
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```

Approved CWL callers MUST keep these explicit mappings and MUST NOT use
`secrets: inherit`; see
[the reusable-workflow secret contract](../../docs/doctoring/deploy-pages-secret-contract.md).

The reusable workflow publishes `build_dir` to the named Pages project (creating
it on first run) via `wrangler pages deploy`, then idempotently attaches
`custom_domain` if provided. After attaching a custom domain, add the matching
Expand Down
Loading
Loading