landing-page-ci #4533
Workflow file for this run
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-ci | |
| on: | |
| pull_request: | |
| paths: | |
| # Workflow files | |
| - .github/workflows/landing-page-ci.yml | |
| - .github/workflows/landing-page-staging.yml | |
| - .github/workflows/landing-page-production.yml | |
| - .github/workflows/blog-indexing-on-deploy.yml | |
| # Landing page sources | |
| - apps/landing-page/** | |
| # Design template source of truth for the homepage. | |
| - design-templates/open-design-landing/** | |
| # Content sources globbed by Astro content collections β without | |
| # these the deploy can be silently skipped when only Markdown | |
| # content is touched. | |
| - skills/** | |
| - design-systems/** | |
| - craft/** | |
| - templates/** | |
| # Plugin manifests power the bundled-plugin catalog and the new | |
| # `_lib/bundled-plugins.ts` reader; CI must rerun when their | |
| # `title_i18n` / `description_i18n` maps or other fields change. | |
| - plugins/** | |
| # Workspace plumbing | |
| - package.json | |
| - pnpm-lock.yaml | |
| - pnpm-workspace.yaml | |
| # Merge queue trigger so PRs that touch the same paths can clear | |
| # `Validate landing page` / `Strict PR visual tests` while queued. | |
| # Without this branch ruleset blocks merges (the queue waits forever | |
| # for a check name that never gets dispatched against the merge_group | |
| # ref), which is the exact deadlock observed during the 5/26 release | |
| # window. | |
| merge_group: | |
| types: [checks_requested] | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - .github/workflows/landing-page-ci.yml | |
| - .github/workflows/landing-page-staging.yml | |
| - .github/workflows/landing-page-production.yml | |
| - .github/workflows/blog-indexing-on-deploy.yml | |
| - apps/landing-page/** | |
| - design-templates/open-design-landing/** | |
| - skills/** | |
| - design-systems/** | |
| - craft/** | |
| - templates/** | |
| - plugins/** | |
| - package.json | |
| - pnpm-lock.yaml | |
| - pnpm-workspace.yaml | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # Needed to post/update the preview-URL comment on the PR. Fork PRs run | |
| # with a read-only token regardless, so the preview steps below are gated | |
| # to same-repo branches. | |
| pull-requests: write | |
| concurrency: | |
| group: landing-page-ci-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| validate: | |
| name: Validate landing page | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| - name: Restore generated previews | |
| id: previews-cache | |
| uses: actions/cache/restore@v5 | |
| 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 }}- | |
| # Cache the Playwright browser binaries between runs. The cache key | |
| # is pinned to the playwright version we depend on (kept in | |
| # apps/landing-page/package.json) so a bump invalidates correctly. | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| package-json-path: apps/landing-page/package.json | |
| install-command: 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 the per-skill / per-template thumbnail PNGs *before* | |
| # the build so they ship in `out/previews/` automatically. The | |
| # script itself decides what's a soft vs. hard failure: a single | |
| # broken `example.html` is logged and skipped, but a chromium | |
| # launch failure or a 100%-failure run exits non-zero so the | |
| # build stops instead of silently shipping zero thumbnails. | |
| - 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 | |
| # No PUBLIC_GA_MEASUREMENT_ID for PR/CI builds: the per-PR preview must | |
| # not report into the production GA property. OD_LANDING_NOINDEX=1 keeps | |
| # the PR preview (pr-<n>.open-design-landing-staging.pages.dev) out of | |
| # search engines. Only `landing-page-production` builds without these. | |
| - name: Build landing page | |
| env: | |
| OD_LANDING_NOINDEX: '1' | |
| run: pnpm --filter @open-design/landing-page build:static | |
| - name: Save generated previews | |
| if: ${{ steps.previews-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} | |
| uses: actions/cache/save@v5 | |
| 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/**') }} | |
| - name: Lint changed blog SEO | |
| # The build above sets OD_LANDING_NOINDEX=1, so every rendered page in | |
| # `out/` carries `noindex`. Pass the same flag through so the SEO linter | |
| # treats that staging-only noindex as expected instead of an | |
| # indexability blocker (production builds leave the flag unset). | |
| env: | |
| OD_LANDING_NOINDEX: '1' | |
| run: | | |
| BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}" | |
| if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then | |
| # merge_group (and first-push) events have no base SHA. Resolve a | |
| # concrete commit instead of passing the literal "HEAD^", which the | |
| # blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed). | |
| BASE="$(git rev-parse HEAD^)" | |
| fi | |
| pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/lint-blog-seo.ts \ | |
| --base "$BASE" \ | |
| --head HEAD \ | |
| --rendered-out apps/landing-page/out | |
| - name: Guard blog URL changes | |
| run: | | |
| BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}" | |
| if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then | |
| # merge_group (and first-push) events have no base SHA. Resolve a | |
| # concrete commit instead of passing the literal "HEAD^", which the | |
| # blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed). | |
| BASE="$(git rev-parse HEAD^)" | |
| fi | |
| pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/check-blog-url-changes.ts \ | |
| --base "$BASE" \ | |
| --head HEAD | |
| - 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 | |
| # --- PR preview deploy ------------------------------------------------- | |
| # Publish this PR's built site to its own preview URL in the STAGING | |
| # project (`--branch=pr-<number>`) so reviewers see the rendered result | |
| # before merge. It lands in the staging project, never the production | |
| # project. Gated to same-repo branches: fork PRs run without the | |
| # Cloudflare secrets and with a read-only token, so they skip the | |
| # deploy/comment and keep just the validation above. | |
| - name: Prepare staging Pages configuration | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| run: cp apps/landing-page/wrangler.staging.toml apps/landing-page/wrangler.toml | |
| - name: Apply attribution ledger migrations (staging) | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| 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-staging-attribution --remote | |
| - name: Deploy PR preview to Cloudflare Pages | |
| id: preview | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| 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-staging | |
| --branch=pr-${{ github.event.pull_request.number }} | |
| - name: Comment preview URL on PR | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository | |
| uses: actions/github-script@v7 | |
| env: | |
| DEPLOY_URL: ${{ steps.preview.outputs.deployment-url }} | |
| ALIAS_URL: ${{ steps.preview.outputs.pages-deployment-alias-url }} | |
| with: | |
| script: | | |
| const marker = '<!-- landing-preview -->'; | |
| const deploy = process.env.DEPLOY_URL || ''; | |
| const alias = | |
| process.env.ALIAS_URL || | |
| `https://pr-${context.issue.number}.open-design-landing-staging.pages.dev`; | |
| const sha = context.payload.pull_request.head.sha.slice(0, 7); | |
| const body = [ | |
| marker, | |
| '### π Landing page preview', | |
| '', | |
| 'This PR is deployed to a Cloudflare Pages preview β **not** staging or production:', | |
| '', | |
| `- Stable alias: ${alias}`, | |
| deploy ? `- This build: ${deploy}` : '', | |
| '', | |
| `Updated for commit \`${sha}\`.`, | |
| ] | |
| .filter(Boolean) | |
| .join('\n'); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body, | |
| }); | |
| } |