Skip to content

landing-page-production #67

landing-page-production

landing-page-production #67

name: landing-page-production
# Promotes the current landing page to PRODUCTION: the `open-design-landing`
# Cloudflare Pages project, served at open-design.ai. This is the ONLY
# workflow that names the production project, and it is manual-only
# (workflow_dispatch) β€” a merge to `main` can never reach production on its
# own; it only updates the staging project (staging.open-design.ai) via
# `landing-page-staging`. Gate this further by configuring required reviewers
# on the GitHub `production` environment (Settings β†’ Environments).
#
# The build is identical to staging/CI, so what you reviewed on
# staging.open-design.ai is what ships.
on:
workflow_dispatch:
inputs:
reason:
description: 'Why promote now? (recorded in the run log)'
required: false
permissions:
contents: read
deployments: write
# Never cancel an in-flight production deploy.
concurrency:
group: landing-page-production
cancel-in-progress: false
jobs:
deploy:
name: Deploy landing page to production
# Production ships `main` only. workflow_dispatch can be launched from any
# ref via the Actions "Use workflow from" dropdown; gate the whole job on
# 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'
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: production
url: https://open-design.ai
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
# The job-level gate above ensures this dispatch came from main.
# Pin checkout to the dispatch SHA, not floating main, so the
# workflow_run.head_sha used by follow-up QA/reporting jobs is exactly
# the commit that production shipped.
ref: ${{ github.sha }}
- name: Verify production checkout commit
run: |
set -euo pipefail
deployed_sha="$(git rev-parse HEAD)"
if [ "$deployed_sha" != "$GITHUB_SHA" ]; then
echo "::error::production checkout resolved $deployed_sha but workflow dispatch SHA is $GITHUB_SHA"
exit 1
fi
main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
if [ "$GITHUB_SHA" != "$main_sha" ]; then
echo "::error::refusing production deploy for stale workflow SHA $GITHUB_SHA; current origin/main is $main_sha"
echo "::error::dispatch a fresh production workflow from current main, or add an explicit rollback path instead of rerunning an old deploy."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache generated previews
id: previews-cache
uses: actions/cache@v5.0.5
with:
path: apps/landing-page/public/previews
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
restore-keys: |
landing-page-previews-${{ runner.os }}-
- name: Cache Playwright browsers
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
# Generate previews before build so they end up in `out/previews/`.
# Soft vs. hard failure is enforced inside the script itself:
# individual broken `example.html` entries are logged and skipped,
# but a systemic failure (chromium launch error, every job failing)
# exits non-zero so we don't silently ship a deploy with zero
# thumbnails to production.
- name: Generate skill + template previews
# Exact previews-cache hit β‡’ public/previews already holds the correct
# thumbnails, skip the slow Playwright render. A restore-keys partial
# hit keeps cache-hit false, so we still regenerate β€” no stale-thumbnail
# drift.
if: steps.previews-cache.outputs.cache-hit != 'true'
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
env:
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
run: pnpm --filter @open-design/landing-page build:static
- name: Verify zero external JavaScript
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const forbidden = [
/<script\b[^>]*\bsrc=/i,
/type=["']module["']/i,
/\/_astro\/[^"'<>\s]+\.js/i,
];
for (const pattern of forbidden) {
if (pattern.test(html)) {
console.error(`Unexpected client JavaScript matched ${pattern}`);
process.exit(1);
}
}
NODE
- name: Verify homepage ships optimized WebP art
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
// The homepage serves its hero / gallery / method art as optimized,
// origin-hosted WebP (each kept <1MB by the changed-file blob guard)
// instead of Cloudflare Image Resizing variants β€” see PR #4158, the
// local-first homepage design refresh. Require the same floor of 16
// optimized references so a regression that reintroduces raw,
// unoptimized art (or drops the gallery) is still caught.
const webpRefs = html.match(/\/[A-Za-z0-9/_-]+\.webp/g) ?? [];
if (webpRefs.length < 16) {
console.error(`Expected at least 16 optimized WebP image references, found ${webpRefs.length}`);
process.exit(1);
}
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
console.error('Found local /assets/*.png image reference in generated landing HTML.');
process.exit(1);
}
NODE
- name: Verify deploy file count under Cloudflare Pages cap
run: |
# Cloudflare Pages rejects deployments over 20,000 files. Localized
# routes (especially per-locale catalog detail pages) can silently
# multiply the output, so fail well before the hard cap β€” a regression
# is caught here instead of at the wrangler deploy step. `out/`
# already includes the generated previews at this point.
count=$(find apps/landing-page/out -type f | wc -l | tr -d '[:space:]')
echo "landing-page deploy file count: $count"
if [ "$count" -ge 19000 ]; then
echo "::error::landing-page build emitted $count files; Cloudflare Pages caps deployments at 20,000. Reduce per-locale page fan-out (see PR #4158)."
exit 1
fi
# `--branch=main` IS the Cloudflare Pages production branch, so this
# publishes to the production domain (open-design.ai).
- name: Apply attribution ledger migrations (production)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing-page
packageManager: npm
wranglerVersion: "4.110.0"
command: d1 migrations apply open-design-landing-attribution --remote
- name: Deploy to Cloudflare Pages (production)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing-page
packageManager: npm
command: >
pages deploy out
--project-name=open-design-landing
--branch=main
# 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
# non-main ref skips `deploy`, and we don't want a "deploy failed" card for a
# job that never started. Reuses the landing webhook (falling back to the
# release webhook), same as landing-page-daily-feishu.
notify:
name: Notify Feishu (landing production deploy)
needs: deploy
if: ${{ github.repository == 'nexu-io/open-design' && always() && needs.deploy.result != 'skipped' && needs.deploy.result != 'cancelled' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# actions:read lets the card find the previous successful production run
# to compute the changelog baseline; contents:read for the checkout.
contents: read
actions: read
steps:
- name: Checkout deployed commit
uses: actions/checkout@v6.0.2
with:
# Anchor on the exact commit production shipped and fetch full history
# so the changelog range (previous deploy .. this deploy) resolves.
ref: ${{ github.sha }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Check landing deploy Feishu script
run: node --experimental-strip-types .github/scripts/landing-page-deploy-feishu.ts self-check
- name: Post landing deploy card to Feishu
env:
FEISHU_WEBHOOK: ${{ secrets.FEISHU_LANDING_WEBHOOK || secrets.FEISHU_RELEASE_WEBHOOK }}
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_LANDING_SIGN_SECRET || secrets.FEISHU_RELEASE_SIGN_SECRET }}
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
DEPLOY_SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
REASON: ${{ github.event.inputs.reason }}
PRODUCTION_URL: https://open-design.ai
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node --experimental-strip-types .github/scripts/landing-page-deploy-feishu.ts