fix(landing): short HTML edge TTL + host purge after production deploy (#6552)

* fix(landing): shorten HTML edge TTL and host-purge after production deploy

Long s-maxage=3600 + stale-while-revalidate=86400 left locale HTML
(/zh/pricing/, /de/pricing/, …) on different edge objects after
landing-page-production, so some languages looked deployed and others
did not. Cap HTML/edge docs at s-maxage=60 without multi-hour SWR, and
purge open-design.ai by hostname after pages deploy (token needs Zone
Cache Purge). Hashed _astro assets stay immutable.

* ci(landing): add manual host-purge workflow for edge cache recovery

Allows testing CLOUDFLARE_API_TOKEN Cache Purge permission and one-click
recovery without a full production rebuild. Production deploy still purges
after pages deploy.

* ci(landing): support purge-only production workflow mode

Lets us exercise CLOUDFLARE_API_TOKEN Cache Purge without a full rebuild,
and keeps host purge as a post-deploy job after a real promote.

* ci(landing): move edge-cache purge helper to workflow-owned TypeScript

pnpm guard rejects project-owned .mjs, so the new purge helper blocked
Validate workspace. Convert it to TypeScript and move it next to the other
landing workflow helpers under .github/scripts/, invoked with
--experimental-strip-types like landing-page-deploy-feishu.ts.

Also drop the purge-only production mode: blog-indexing-on-deploy listens on
successful landing-page-production runs and cannot tell a cache-only recovery
run from a real deploy. Purging without a rebuild already has a dedicated
entry point in landing-edge-cache-purge, so production purge now runs only
after a successful deploy.
This commit is contained in:
lefarcen
2026-08-13 14:46:04 +00:00
committed by GitHub
parent aab3be9174
commit dba64f2e45
5 changed files with 201 additions and 19 deletions
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Purge Cloudflare edge cache for the marketing host(s) by hostname.
//
// Used by `landing-page-production` after `pages deploy` so locale HTML
// (`/`, `/zh/pricing/`, …) does not keep serving pre-deploy objects when
// CF Pages' deploy invalidation is incomplete for the custom domain, and by
// `landing-edge-cache-purge` for manual incident recovery / token checks.
//
// Inputs (all via env):
// CLOUDFLARE_API_TOKEN (required) token with Zone → Cache Purge on the zone
// CLOUDFLARE_ZONE_ID (optional) defaults to the open-design.ai zone id
// CLOUDFLARE_PURGE_HOSTS (optional) comma-separated hostnames, default
// `open-design.ai`
//
// Scope: hosts purge is intentional — one host, all paths/locales, without
// touching other hostnames on the same zone (e.g. download.open-design.ai).
//
// @see https://developers.cloudflare.com/api/resources/cache/methods/purge/
// @see https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-hostname/
type PurgeResponse = {
success?: boolean;
errors?: unknown;
result?: unknown;
};
const DEFAULT_ZONE_ID = "84ed4658186179c7eba52659b6ef48ad";
const token = process.env.CLOUDFLARE_API_TOKEN?.trim();
const zoneId = process.env.CLOUDFLARE_ZONE_ID?.trim() || DEFAULT_ZONE_ID;
const hosts = (process.env.CLOUDFLARE_PURGE_HOSTS || "open-design.ai")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
if (!token) {
console.error("CLOUDFLARE_API_TOKEN is required");
process.exit(1);
}
if (hosts.length === 0) {
console.error("CLOUDFLARE_PURGE_HOSTS resolved to an empty host list");
process.exit(1);
}
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ hosts }),
},
);
const payload = (await response
.json()
.catch(() => null)) as PurgeResponse | null;
const ok = response.ok && payload?.success === true;
console.log(
JSON.stringify(
{
httpStatus: response.status,
zoneId,
hosts,
success: ok,
errors: payload?.errors ?? null,
result: payload?.result ?? null,
},
null,
2,
),
);
if (!ok) {
console.error(
`Cloudflare host purge failed. Ensure CLOUDFLARE_API_TOKEN has Zone.Cache Purge on ${hosts.join(", ")}.`,
);
process.exit(1);
}
@@ -0,0 +1,41 @@
name: landing-edge-cache-purge
# Manual / test entry to host-purge open-design.ai (or staging) without a full
# production rebuild. Production deploy also purges after pages deploy; this
# workflow is for incident recovery and permission checks.
#
# Requires secrets.CLOUDFLARE_API_TOKEN with Zone → Cache Purge on the target
# zone. Optional: vars.CLOUDFLARE_ZONE_ID.
on:
workflow_dispatch:
inputs:
hosts:
description: 'Comma-separated hostnames to purge (no scheme)'
required: true
default: 'open-design.ai'
permissions:
contents: read
jobs:
purge:
name: Purge Cloudflare edge cache by hostname
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Purge hosts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_PURGE_HOSTS: ${{ inputs.hosts }}
run: node --experimental-strip-types .github/scripts/landing-page-purge-edge-cache.ts
+34 -1
View File
@@ -35,7 +35,9 @@ jobs:
# the main ref so a dispatch from a feature branch/tag is skipped outright
# (no deploy) instead of recording a non-main production run — which would
# also dodge blog-indexing's `workflow_run` `branches: [main]` filter.
if: github.repository == 'nexu-io/open-design' && github.ref == 'refs/heads/main'
if: >
github.repository == 'nexu-io/open-design'
&& github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
@@ -205,6 +207,37 @@ jobs:
--project-name=open-design-landing
--branch=main
# CF Pages deploy does not reliably invalidate custom-domain HTML across every
# POP when `_headers` allows edge caching. Purge by hostname so all locale
# paths (`/`, `/zh/pricing/`, …) drop together without purge_everything on
# the whole zone (keeps download.open-design.ai etc.).
# Token needs Zone → Cache Purge. Optional: set vars.CLOUDFLARE_ZONE_ID.
# Only runs after a real deploy. Purging without a rebuild (incident recovery,
# token permission check) belongs to the dedicated `landing-edge-cache-purge`
# workflow, so a cache-only run never reaches this workflow's `workflow_run`
# consumers (notably `blog-indexing-on-deploy`).
purge:
name: Purge open-design.ai edge cache (hosts)
needs: deploy
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Purge open-design.ai edge cache (hosts)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_PURGE_HOSTS: open-design.ai
run: node --experimental-strip-types .github/scripts/landing-page-purge-edge-cache.ts
# Post a Feishu card to the landing group once the promotion settles. Runs on
# BOTH success and failure (a failed production deploy is exactly when people
# need a ping), but only when `deploy` actually ran — a dispatch from a