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
137 changes: 122 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

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-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-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) <= 100
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()

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()

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,19 @@ 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
{
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 @@ -13,6 +13,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.
- Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics.
- Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed.
- Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped.
Expand Down
112 changes: 112 additions & 0 deletions docs/doctoring/deploy-pages-secret-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 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` is bounded to a non-empty alphanumeric/hyphen identifier 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.
`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-09, a current organization search found no product workflow that
calls this reusable workflow. Re-run that 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). *Direct Upload*. Cloudflare Pages documentation.
https://developers.cloudflare.com/pages/get-started/direct-upload/

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

GitHub. (2026). *Reuse workflows*. GitHub Docs.
https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows
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