open-design/.github/workflows/landing-page-ci.yml
lefarcen 9a4816b101
feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger (#2999)
* feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger

Why: a merged plugin PR didn't redeploy the landing site (plugins/** was missing from the deploy paths), and the desktop Share menu copied a local/404 link instead of the public marketplace URL. The landing plugin routing left by the detail-page rework also 404'd: the locale listing's cards used a multi-segment href while detail pages were single-segment, and only 388 bundled _official plugins had pages.

What changed:
- Deploy: landing-page deploy/ci trigger on plugins/**, and skip the slow previews step on an exact cache hit (cache key aligned across both workflows so a PR-built cache is reused by main).
- Share URL: packages/contracts/plugin-url.ts owns the single-segment plugin URL scheme; the web Share menu and the landing site both derive links from it. Web links now point at https://open-design.ai/plugins/<slug>/.
- Full detail coverage: detail pages now cover all 403 local plugins (_official incl. atoms + community), each rendered from its local manifest. Fixes the locale-listing 404s and the community manifest-name/catalog-id (- vs /) mismatch.
- Self-host: daemon exposes OD_SITE_ORIGIN via /api/app-config; web falls back to the canonical origin until the daemon answers.

Validation: pnpm guard, pnpm typecheck (all packages), contracts + web tests green, and a full build E2E confirming all 403 catalog ids and locale-listing cards resolve to built detail pages (0 missing).

* chore: retrigger CI

* ci(landing): carry plugins/** trigger + previews cache-hit into #2994 split workflows

Merged origin/main, which split landing deploy into staging + manual production (#2994). git auto-migrated my landing-page-deploy.yml changes into landing-page-staging.yml via rename detection (plugins/** path, fallback-preview-card.ts cache key, cache-hit skip all carried). The new manual landing-page-production.yml didn't have them, so add the previews cache-key alignment + cache-hit skip there too (plugins/** path is N/A — production is workflow_dispatch only).

* fix(ci): wrangler-action uses pnpm so it tolerates landing's workspace dep

This PR added @open-design/contracts (workspace:*) to apps/landing-page/package.json so the landing site can share the plugin-url slug rules. But the landing deploy/preview steps run cloudflare/wrangler-action with packageManager: npm in workingDirectory apps/landing-page, and 'npm i wrangler' chokes on the workspace: protocol (EUNSUPPORTEDPROTOCOL), failing 'Validate landing page'. Switch all three landing wrangler-action steps (staging / ci preview / production) to packageManager: pnpm, which is workspace-aware.

* test(e2e): bundled plugins now offer the README badge

After this branch, buildPluginShareUrl returns a public open-design.ai link for bundled plugins (not just official-marketplace ones), so the home-starter share menu now shows 'Copy README badge'. Update the assertion from toHaveCount(0) to toBeVisible().

* fix(landing): drop @open-design/contracts dep, use a landing-local slug helper

Per review on #2999: the marketing site must not import @open-design/contracts (AGENTS.md boundary — it's the web/daemon product-runtime contract layer). Move the slug/path helpers into landing-local app/_lib/plugin-slug.ts; the web client keeps contracts' plugin-url. The two derive the same scheme and are verified in lockstep by the e2e route check (403 share URLs -> 403 detail pages, 0 missing). landing no longer has a workspace dep, so revert the wrangler-action packageManager back to npm.

* fix(landing): include plugins/_official in previews cache key

Per review on #2999: generate-previews.ts builds bundled-plugin preview jobs from plugins/_official/**/open-design.json and renders fallback cards from manifest fields (title/description/mode/scenario/tags). With plugins/** now triggering the workflow but the cache key not hashing plugin inputs, a plugin-only PR/merge could exact-hit an old cache and skip the preview regen, shipping with a stale or missing /previews/plugins/<manifest-id>.png. Add plugins/_official/** to the cache key in all three landing workflows (ci, staging, production). community is not currently covered by generate-previews so its glob is omitted.

* fix(plugins): include community marketplace installs in share gate

hasPublicPage now covers sourceMarketplaceId === 'community' so the
README badge and public detail link surface for community installs.
Community manifest names carry a community- prefix that diverges from
the landing-page route slug, so URL derivation uses sourceMarketplaceEntryName
(community/<folder>) instead — pluginDetailSlug takes the last segment,
matching the /plugins/<folder>/ route the landing page emits.
Adds component tests for buildPluginShareUrl, badge copy, and the
Open-in-marketplace link for a community/registry-starter record.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-28 09:07:12 +00:00

260 lines
11 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/**
# 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
- .github/workflows/blog-indexing-monitor.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: 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 }}-
# 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: Lint changed blog SEO
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 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,
});
}