Skip to content
Merged
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
96 changes: 96 additions & 0 deletions .github/workflows/cloudflare-dns.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Cloudflare DNS as code (curl + jq, no Terraform).
#
# Reconciles infra/cloudflare/zones.json against the Cloudflare account:
# - ensures each declared zone exists (creates via POST /zones in apply mode)
# - upserts the declared DNS records (no destructive deletes unless prune=true)
# - prints each zone's Cloudflare nameservers + status so they can be set at
# Namecheap (registrar) to delegate the domain to Cloudflare.
#
# Auth comes from org GitHub Secrets (scope=ALL): the token never leaves trusted
# push/dispatch runs. Pull requests run offline config validation only.
# CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
#
# Default is DRY-RUN. Set input mode=apply to actually write.
name: Cloudflare DNS

on:
workflow_dispatch:
inputs:
mode:
description: "dry-run (default, no writes) or apply (create zones + records)"
type: choice
default: dry-run
options:
- dry-run
- apply
prune:
description: "Delete Cloudflare records not present in zones.json (destructive)"
type: boolean
default: false
push:
branches: [main]
paths:
- "infra/cloudflare/zones.json"
- "infra/cloudflare/reconcile.sh"
- ".github/workflows/cloudflare-dns.yml"
# PRs validate the declarative config without Cloudflare secrets. Push and
# workflow_dispatch runs perform the API-backed dry-run/apply.
pull_request:
paths:
- "infra/cloudflare/zones.json"
- "infra/cloudflare/reconcile.sh"
- ".github/workflows/cloudflare-dns.yml"

# push-triggered runs are always dry-run (safe by default);
# only an explicit workflow_dispatch with mode=apply is allowed to write.
concurrency:
group: cloudflare-dns-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

jobs:
reconcile:
name: Reconcile zones (${{ github.event.inputs.mode || 'dry-run' }})
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Validate Cloudflare DNS config
if: ${{ github.event_name == 'pull_request' }}
run: |
set -euo pipefail
jq -e '
(.zones | type == "array" and length > 0) and
all(.zones[]; (
(.zone_name | type == "string" and length > 0) and
(.product_repo | type == "string" and length > 0) and
(.product_label | type == "string" and length > 0) and
(.records | type == "array") and
all(.records[]; (
(.record_type | type == "string" and length > 0) and
(.record_name | type == "string" and length > 0) and
(.record_content | type == "string" and length > 0)
))
))
' infra/cloudflare/zones.json >/dev/null
count="$(jq '.zones | length' infra/cloudflare/zones.json)"
echo "Validated ${count} Cloudflare DNS zone declaration(s) without exposing Cloudflare secrets to pull request code."

- name: Reconcile Cloudflare DNS
if: ${{ github.event_name != 'pull_request' }}
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CF_MODE: ${{ github.event.inputs.mode || 'dry-run' }}
CF_PRUNE: ${{ github.event.inputs.prune || 'false' }}
CF_CONFIG: infra/cloudflare/zones.json
run: |
set -euo pipefail
if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then
echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID secrets are not available to this run."
exit 1
fi
bash infra/cloudflare/reconcile.sh
112 changes: 112 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Reusable Cloudflare Pages deploy (workflow_call).
#
# 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`.
#
# Example caller (.github/workflows/site.yml in a product repo):
#
# name: Publish marketing site
# on:
# push:
# branches: [main]
# jobs:
# deploy:
# uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main
# with:
# 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
#
name: Deploy Cloudflare Pages

on:
workflow_call:
inputs:
project_name:
description: "Cloudflare Pages project name (created on first deploy if absent)"
required: true
type: string
build_dir:
description: "Directory containing the built static assets to publish"
required: true
type: string
custom_domain:
description: "Optional custom domain to attach to the Pages project (zone must exist)"
required: false
type: string
default: ""

permissions:
contents: read

jobs:
deploy_pages:
name: Deploy ${{ inputs.project_name }}
runs-on: ubuntu-latest
steps:
- name: Checkout caller repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Guard secrets present
env:
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
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'."
exit 1
fi

- name: Deploy to Cloudflare Pages (wrangler)
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.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 }}

- name: Attach custom domain (idempotent)
if: ${{ inputs.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 }}
run: |
set -euo pipefail
api="https://api.cloudflare.com/client/v4"
base="${api}/accounts/${CF_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/domains"

existing="$(curl -sS "${base}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
| jq -r --arg d "${CUSTOM_DOMAIN}" '(.result // [])[] | select(.name==$d) | .name')"

if [ "${existing}" = "${CUSTOM_DOMAIN}" ]; then
echo "Custom domain ${CUSTOM_DOMAIN} already attached to ${PROJECT_NAME}."
else
echo "Attaching ${CUSTOM_DOMAIN} to Pages project ${PROJECT_NAME}..."
resp="$(curl -sS -X POST "${base}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "$(jq -nc --arg n "${CUSTOM_DOMAIN}" '{name:$n}')")"
if [ "$(printf '%s' "${resp}" | jq -r '.success // false')" = "true" ]; then
echo "Attached ${CUSTOM_DOMAIN}."
else
echo "::warning::Could not attach ${CUSTOM_DOMAIN}: $(printf '%s' "${resp}" | jq -c '.errors // .')"
fi
fi

- name: Summary
if: always()
run: |
{
echo "## Cloudflare Pages deploy"
echo ""
echo "- **Project:** \`${{ inputs.project_name }}\`"
echo "- **Build dir:** \`${{ inputs.build_dir }}\`"
echo "- **Custom domain:** \`${{ inputs.custom_domain || '(none)' }}\`"
} >> "$GITHUB_STEP_SUMMARY"
114 changes: 114 additions & 0 deletions infra/cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Cloudflare DNS + Pages (infrastructure as code)

This directory manages the org's domains and static hosting on **Cloudflare**,
declaratively, with nothing more than `curl` + `jq` running inside GitHub
Actions. No Terraform — six zones do not justify the moving parts.

## The model

- **Cloudflare is the DNS authority _and_ the host.** Each domain becomes a
Cloudflare *zone*; static marketing sites are served from **Cloudflare Pages**
(Workers can be added later the same way).
- **Namecheap only holds the registration.** After a zone is created in
Cloudflare, you do a **one-time** step at Namecheap: replace the default
Namecheap nameservers with the two nameservers Cloudflare assigns to that
zone. From then on, all records are managed here in `zones.json`.
- **The Cloudflare API token is never exposed to pull request code.** It lives
as the org secret `CLOUDFLARE_API_TOKEN` (with `CLOUDFLARE_ACCOUNT_ID`). Pull
requests validate `zones.json` offline. Only trusted `main` pushes and manual
workflow runs touch the Cloudflare API with
`${{ secrets.CLOUDFLARE_API_TOKEN }}` / `${{ secrets.CLOUDFLARE_ACCOUNT_ID }}`.

## Files

| File | Purpose |
| ---- | ------- |
| `zones.json` | Declarative list of the 6 zones + their DNS records (data-driven; records start empty). |
| `reconcile.sh` | Idempotent reconciler (curl + jq). Ensures zones exist, upserts records, prints nameservers. |
| `../../.github/workflows/cloudflare-dns.yml` | Runs `reconcile.sh` with the org secrets. Dry-run by default. |
| `../../.github/workflows/deploy-pages.yml` | Reusable (`workflow_call`) Cloudflare Pages deploy any product repo can call. |

## Domain ↔ product map

| Domain | Product repo | Product |
| ------ | ------------ | ------- |
| `keyverse.io` | `cwl-idp` | Keyverse |
| `wardnet.io` | `waf-ids-ai-soc` | Wardnet |
| `inkspan.io` | `cwl-editor` | Inkspan |
| `cloud-erd.app` | `pg-erd-cloud` | Cloud ERD |
| `naruon.net` | `naruon` | Naruon |
| `naruon.io` | `naruon` | Naruon |

## One-time setup per domain (Namecheap → Cloudflare)

1. **Create the zone + read its nameservers.** Run the `Cloudflare DNS`
workflow in **apply** mode (Actions tab → *Cloudflare DNS* →
*Run workflow* → `mode = apply`). For any zone that does not exist yet, it
creates it via `POST /zones` and prints the two assigned nameservers and the
zone status to the job summary (and the run log).
2. **Point Namecheap at Cloudflare.** In Namecheap → *Domain List* → *Manage* →
*Nameservers* → **Custom DNS**, enter the two Cloudflare nameservers reported
for that domain, and save.
3. **Wait for activation.** The zone status is `pending` until Namecheap
delegation propagates (minutes to a few hours), then flips to `active`.
Re-running the workflow re-prints the current status.

## Adding DNS records (once a Pages project exists)

Records are intentionally empty for now because the Pages hosting targets do not
exist yet. To add one, edit `zones.json` and append to the target zone's
`records` array using this shape:

```json
{
"record_type": "CNAME",
"record_name": "keyverse.io",
"record_content": "keyverse-marketing.pages.dev",
"record_proxied": true,
"record_ttl": 1
}
```

Then either push to `main` (the workflow runs a **dry-run** automatically) or
run the workflow manually with `mode = apply`. Reconciliation is idempotent:
existing records are updated in place, missing ones are created. Nothing is
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:

```yaml
# .github/workflows/site.yml in e.g. cwl-idp (Keyverse)
name: Publish marketing site
on:
push:
branches: [main]
jobs:
deploy:
uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main
with:
project_name: keyverse-marketing
build_dir: ./public
custom_domain: keyverse.io # optional; the CF zone must already exist
secrets: inherit
```

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
DNS record to `zones.json` (usually a proxied `CNAME` to `<project>.pages.dev`)
so the apex/`www` resolves to the site.

This same reusable workflow is the deploy path for the per-product marketing
pages and for the org `github.io` / profile content.

## Safety notes

- **Dry-run is the default.** Pull requests run offline config validation,
`workflow_dispatch` defaults to `mode = dry-run`, and `push` events are
always dry-run — only an explicit manual run with `mode = apply` writes to
Cloudflare.
- **No destructive deletes** unless `prune = true` is set explicitly.
- **Fail-soft:** per-zone/record errors are logged and the run continues; only a
failed API-token verification aborts the job (so a broken token is loud).
Loading
Loading