landing-page-production #55
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
| 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: 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 |