diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 99d3f15..6f9906b 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -82,10 +82,31 @@ jobs: - name: Deploy Cloudflare infrastructure run: npm run iac:deploy -- --stage prod - + - name: Attach apex domain to Pages project + run: | + chmod +x scripts/attach-pages-domain.sh + ./scripts/attach-pages-domain.sh codecolony.online + + - name: Verify apex domain + # First Pages deploy of a new custom domain can take 30-90s for + # Cloudflare to issue the TLS cert; retry briefly. --fail-with-body + # makes curl exit non-zero on 4xx/5xx so the workflow surfaces real + # outages. + run: | + for attempt in 1 2 3 4 5 6; do + if curl --fail-with-body -sS -o /dev/null \ + -w 'HTTP %{http_code} (attempt '$attempt')\n' \ + https://codecolony.online/; then + exit 0 + fi + echo "Apex not ready yet, sleeping..." >&2 + sleep 15 + done + echo "::error::Apex codecolony.online never returned 2xx after 6 attempts." >&2 + exit 1 - if: always() && env.SLACK_WEBHOOK_URL != '' name: Notify Slack run: | curl -X POST -H 'Content-type: application/json' \ --data "{\"text\":\"Code Colony prod deploy finished with status: ${{ job.status }}\"}" \ - "$SLACK_WEBHOOK_URL" + "$SLACK_WEBHOOK_URL" \ No newline at end of file diff --git a/alchemy/dns.ts b/alchemy/dns.ts index 473f751..606b883 100644 --- a/alchemy/dns.ts +++ b/alchemy/dns.ts @@ -52,10 +52,17 @@ export async function dnsStack( `verify delegation before redeploying.`, ); } + const zoneId = zone.id; + void zoneId; - void zone.id; - - // Landing apex/www: deliberately not managed by alchemy. + // Landing apex/www: deliberately not managed by alchemy DnsRecords. + // Cloudflare's zone-create flow auto-provisioned `@`/`www` CNAME records + // (standard zone bootstrap), and recreating them collides with code 81053. + // Instead, the apex domain is attached as a custom domain to the Pages + // project (code-colony-landing) via the Cloudflare API in cd-prod.yml. + // Cloudflare then manages the apex DNS record and TLS automatically. + // See scripts/attach-pages-domain.sh for the API call. + void args.landing; if (args.app.hostname) { await CustomDomain("app", { diff --git a/alchemy/landing.ts b/alchemy/landing.ts index d1e2bd3..eb939b3 100644 --- a/alchemy/landing.ts +++ b/alchemy/landing.ts @@ -5,9 +5,10 @@ * still deployed by the Pages workflow outside this Alchemy stack. * * Alchemy's currently installed Cloudflare provider does not expose a Pages - * resource. DNS therefore CNAMEs the apex subdomain to the stable Pages - * hostname returned here, while previews keep using Cloudflare's default - * Pages/Workers preview URLs. + * resource. The apex domain (codecolony.online) is attached as a custom domain + * to the Pages project via the Cloudflare API in cd-prod.yml. Cloudflare then + * manages the apex DNS record and TLS automatically. + * Previews keep using Cloudflare's default Pages/Workers preview URLs. */ import type Alchemy from "alchemy"; diff --git a/scripts/attach-pages-domain.sh b/scripts/attach-pages-domain.sh new file mode 100755 index 0000000..b54b3e8 --- /dev/null +++ b/scripts/attach-pages-domain.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Attach codecolony.online as a custom domain to the Cloudflare Pages project +# code-colony-landing. Idempotent: re-attaches/repairs if the domain is present +# but not in a healthy verified state. +# +# Environment: +# CLOUDFLARE_ACCOUNT_ID (required) +# CLOUDFLARE_API_TOKEN (required) +# +# This is used by cd-prod.yml because Alchemy's Cloudflare provider does not +# expose a Pages resource, so we attach the apex domain directly via the +# Cloudflare API. Once attached, Cloudflare manages the apex DNS record +# automatically (proxying + TLS). + +set -euo pipefail + +DOMAIN="${1:-codecolony.online}" +PROJECT="code-colony-landing" +ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required}" +TOKEN="${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}" + +API_BASE="https://api.cloudflare.com/client/v4" +AUTH_HDR="Authorization: Bearer ${TOKEN}" + +echo "==> Checking existing domains on Pages project '${PROJECT}' ..." + +# Fetch the existing domain entry once and inspect both name (canonical) and +# status. The API response field is `.result[].name`; older docs reference +# `.domain`, but the live shape is `name`. We accept either to be safe. +EXISTING_JSON=$(curl -sS -H "${AUTH_HDR}" \ + "${API_BASE}/accounts/${ACCOUNT_ID}/pages/projects/${PROJECT}/domains") + +EXISTING_ENTRY=$(echo "${EXISTING_JSON}" | jq -c \ + --arg dom "${DOMAIN}" \ + '.result[]? | select((.name // .domain) == $dom)') + +if [ -n "${EXISTING_ENTRY}" ]; then + STATUS=$(echo "${EXISTING_ENTRY}" | jq -r '.status // .verification_data.status // "unknown"') + echo "==> Domain '${DOMAIN}' already attached (status=${STATUS})." + case "${STATUS}" in + active|pending|verifying|ssl_pending|deployment_pending|initializing) + echo "==> Status is healthy/in-progress. Nothing to do." + exit 0 + ;; + *) + echo "==> Status '${STATUS}' is not healthy; will attempt re-attach." + ;; + esac +fi + +echo "==> Attaching '${DOMAIN}' to Pages project '${PROJECT}' ..." + +RESPONSE=$(curl -sS -H "${AUTH_HDR}" -H "Content-Type: application/json" \ + -X POST \ + "${API_BASE}/accounts/${ACCOUNT_ID}/pages/projects/${PROJECT}/domains" \ + -d "{\"name\":\"${DOMAIN}\"}") + +SUCCESS=$(echo "${RESPONSE}" | jq -r '.success') + +if [ "${SUCCESS}" != "true" ]; then + ERRORS=$(echo "${RESPONSE}" | jq -r '.errors[]?.message // .errors // empty') + # If the error says the domain already exists, treat as success + if echo "${ERRORS}" | grep -qi "already exists\|already added\|duplicate"; then + echo "==> Domain '${DOMAIN}' already exists (race or previous run). Treating as success." + exit 0 + fi + echo "ERROR: Failed to attach domain '${DOMAIN}': ${ERRORS}" >&2 + echo "Full response: ${RESPONSE}" >&2 + exit 1 +fi + +echo "==> Domain '${DOMAIN}' attached successfully." +echo "==> Cloudflare will provision the apex DNS record and TLS certificate automatically." + +# Verify by listing domains again +echo "==> Current domains on '${PROJECT}':" +curl -sS -H "${AUTH_HDR}" \ + "${API_BASE}/accounts/${ACCOUNT_ID}/pages/projects/${PROJECT}/domains" \ + | jq -r '.result[]? | "\(.name // .domain // "?") (\(.status // "?"))"' + +exit 0