open-design/.github/workflows/landing-page-ci.yml
lefarcen 7312c64580
ci(landing): split landing deploy into staging gate + manual production (#2994)
* ci(landing): split landing deploy into staging gate + manual production

A merge to `main` previously published the landing page straight to
production (open-design.ai) via `landing-page-deploy`. There was no
buffer to review the rendered site, so a bad merge was live instantly.

Split deploys across two Cloudflare Pages projects so production is only
ever reached by an explicit human action:

- `landing-page-staging` (push to main) -> staging project
  `open-design-landing-staging` -> staging.open-design.ai.
- `landing-page-production` (manual workflow_dispatch only) -> production
  project `open-design-landing` -> open-design.ai. Only this workflow
  names the production project; gate it with required reviewers on the
  `production` GitHub environment.
- `landing-page-ci` now also deploys a per-PR preview into the staging
  project (`--branch=pr-<n>`) for same-repo branches and comments the URL.
  Fork PRs (no secrets / read-only token) skip the deploy and keep just
  the build validation. Path filters already scope this to landing edits.

Decouple search-engine indexing from staging:

- `blog-indexing-on-deploy` now triggers on `landing-page-production`
  (not every main push), so the test environment is never submitted to
  Google/IndexNow.
- It diffs from a new `blog-indexed-prod` tag (the last indexed prod
  commit) instead of `HEAD^`, and force-advances the tag after a
  successful run, so a manual promotion bundling several merged posts
  indexes all of them rather than only the last commit.

Staging and PR-preview builds drop `PUBLIC_GA_MEASUREMENT_ID` so test
traffic does not pollute the production GA property.

* ci(landing): keep staging + PR previews out of the search index

staging.open-design.ai mirrors production and is exposed via cert
transparency logs, so search engines can discover it. Indexing the
mirror competes with open-design.ai for the same content.

Emit `<meta name="robots" content="noindex, nofollow">` whenever
OD_LANDING_NOINDEX=1, and set that flag on the staging and PR-preview
builds (production leaves it unset and stays indexable). noindex is
used rather than a robots.txt Disallow so crawlers can still fetch the
page and read both the tag and the canonical, which already points at
the production origin.

* fix(landing): make staging noindex actually take effect

The previous commit read `process.env.OD_LANDING_NOINDEX` directly in
`seo-head.astro`, but `.astro` frontmatter is transformed by Vite and
does not see process.env, so the meta never rendered. Two fixes:

- Inject the flag as the compile-time constant `__OD_LANDING_NOINDEX__`
  via `vite.define` in astro.config.ts (config runs in Node and can read
  process.env); SeoHead consumes that constant.
- The homepage (`index.astro`) and `og.astro` build their own <head> and
  never use SeoHead, so a per-component meta can miss pages. Add an
  `astro:build:done` integration that appends a catch-all
  `/*  X-Robots-Tag: noindex, nofollow` to the Cloudflare Pages `_headers`
  on staging/preview builds, covering every response (homepage, assets,
  any custom-head page) at the HTTP layer. Production builds leave
  `_headers` untouched.

Verified: build with OD_LANDING_NOINDEX=1 emits the _headers block and
the SeoHead <meta>; build without the flag emits neither; astro check
clean.

* fix(landing): address review — pin prod checkout to main, defer index pointer

Two blockers from review:

- landing-page-production: workflow_dispatch can be launched from any ref
  via the Actions "Use workflow from" dropdown, so an operator could ship
  an arbitrary branch to open-design.ai. Pin the checkout to `ref: main`
  so the deployed artifact always equals reviewed main.

- blog-indexing-on-deploy: the `blog-indexed-prod` pointer was advanced
  right after sitemap submission, before Inspect / Search Analytics /
  Render status / Open status PR. A failure in any of those still moved
  the pointer, so the next production run skipped those posts. Move the
  advance to the very end, gated on `success()`, so a failure leaves the
  tag in place and the range is re-processed next run (submissions are
  idempotent).

* fix(landing): gate production promotion to the main ref only

Follow-up to the production-path review note: pinning checkout to main
fixed the deployed content, but the workflow was still dispatchable from
any ref, which records a non-main production run and would dodge
blog-indexing's `workflow_run` `branches: [main]` filter. Gate the whole
job on `github.ref == 'refs/heads/main'` so a dispatch from any other
branch/tag is skipped outright.
2026-05-26 14:05:04 +00:00

236 lines
9.4 KiB
YAML

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
- .github/workflows/blog-indexing-monitor.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/**
# Workspace plumbing
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
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
- .github/workflows/blog-indexing-monitor.yml
- apps/landing-page/**
- design-templates/open-design-landing/**
- skills/**
- design-systems/**
- craft/**
- templates/**
- 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: 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/**') }}
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
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: Lint changed blog SEO
run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}"
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="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 || 'HEAD^' }}"
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="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 Cloudflare image resizing URLs
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
if (resizedUrls.length < 16) {
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.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
# --- 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: 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,
});
}