mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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.
236 lines
9.4 KiB
YAML
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,
|
|
});
|
|
}
|