Repository navigation
feat(cloudflare): DNS-as-code + reusable Pages deploy for 6 org zones #362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
0c37370
feat(cloudflare): add DNS-as-code + reusable Pages deploy for 6 org z…
seonghobae 488852b
fix(cloudflare): pin GitHub Actions to commit SHAs (GHAS Scorecard)
seonghobae c437ded
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] 72a4124
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] c17e69b
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] aa86744
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] 3c56535
fix(cloudflare): avoid secrets in pull request validation
seonghobae 7b88791
Merge remote-tracking branch 'origin/main' into feat/cloudflare-dns-p…
seonghobae 8a86726
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] 9574795
Merge branch 'main' into feat/cloudflare-dns-pages
opencode-agent[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # 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 Actions. | ||
| # 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" | ||
| # Same-repo PRs that touch the config get an automatic dry-run (secrets are | ||
| # available to same-repo PRs; forks get none, so this stays safe). This also | ||
| # lets the config be validated — and nameservers/status captured — before merge. | ||
| pull_request: | ||
| paths: | ||
| - "infra/cloudflare/zones.json" | ||
| - "infra/cloudflare/reconcile.sh" | ||
| - ".github/workflows/cloudflare-dns.yml" | ||
|
|
||
| # push- and pull_request-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@v4 | ||
|
|
||
| - name: Reconcile Cloudflare DNS | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@v4 | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| - 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@v3 | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| 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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # 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 never leaves GitHub Actions.** It lives as the org | ||
| secret `CLOUDFLARE_API_TOKEN` (with `CLOUDFLARE_ACCOUNT_ID`). It is not | ||
| available locally and is never committed. Every operation that touches the | ||
| Cloudflare API runs in a workflow that references | ||
| `${{ 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.** `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). |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.