open-design/apps/landing-page/AGENTS.md
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

7.4 KiB

apps/landing-page/AGENTS.md

Follow the root AGENTS.md and apps/AGENTS.md first. This file only records module-level boundaries for apps/landing-page/.

Purpose

apps/landing-page is a stand-alone static Astro site that renders the Open Design marketing surface in the Atelier Zero style and ships per-facet catalog pages for every skill, design system, craft principle, and live-artifact template in the repo root.

Tightly coupled with:

  • Design template: design-templates/open-design-landing/ — agent workflow + the source-of-truth example.html known-good rendering for the homepage hero.
  • Design system: design-systems/atelier-zero/DESIGN.md — token spec.
  • Image assets: design-templates/open-design-landing/assets/*.png are uploaded to Cloudflare R2 (open-design-static) and served through static.open-design.ai with Image Resizing (format=auto). Do not commit local mirrored PNGs into apps/landing-page/public/assets/.

What it is

  • Astro static output. The site has multiple route groups:
    • / — Atelier Zero homepage (app/pages/index.astro).
    • /skills/ + /skills/<slug>/ — every SKILL.md in skills/.
    • /skills/mode/<slug>/ and /skills/scenario/<slug>/ — facet pages generated from frontmatter via getStaticPaths.
    • /systems/ + /systems/<slug>/ + /systems/category/<slug>/ — every DESIGN.md in design-systems/.
    • /craft/ + /craft/<slug>/ — every *.md in craft/.
    • /templates/ + /templates/<slug>/ — Live Artifacts in templates/live-artifacts/ plus skills with od.mode: template.
  • Content sources are never mirrored into this app. Astro content collections (app/content.config.ts) glob the canonical Markdown bundles in the repo root at build time. When a contributor adds or edits a SKILL.md/DESIGN.md, the next build picks it up — no intermediate "register your skill here" step.
  • The shaped data layer lives in app/_lib/catalog.ts. Page templates import shaped records from there and never re-parse Markdown in JSX.
  • React is used only at build time (renderToStaticMarkup) for app/page.tsx and the shared Header. The output ships CDN-ready HTML/CSS plus a small inline enhancement script; no React runtime ships to browsers.
  • All styles split between app/globals.css (homepage, kept in lockstep with design-templates/open-design-landing/example.html) and app/sub-pages.css (catalog/facet/detail pages).
  • All page imagery is referenced through app/image-assets.ts, which builds Cloudflare Image Resizing URLs for the R2 originals.
  • Per-skill / per-template thumbnails are rendered offline by scripts/generate-previews.ts (Playwright). Output lives in public/previews/<bucket>/<slug>.<ext> and is gitignored — CI regenerates on every deploy. The script preserves the actual file extension so a future sharp/webp post-processor will work without touching the data layer.

What it is NOT

  • Not part of apps/web. The web app is the product surface; the landing page is a marketing surface. They share design tokens but not state, routes, or runtime.
  • Not connected to apps/daemon. There is no /api, no /artifacts, no /frames — no proxy to set up.
  • Not a CMS. Content authors edit Markdown in skills/, design-systems/, craft/, or templates/live-artifacts/ at the repo root; the landing page rebuilds against those globs and ships to Cloudflare Pages automatically.

Boundary constraints

  • Must remain a static Astro output.
  • Must not import from @open-design/web, @open-design/daemon, @open-design/desktop, @open-design/sidecar*, or @open-design/contracts. Those are product runtime concerns.
  • Must not introduce a src/ shell — keep all source under app/. Component bundles live in app/_components/<name>.{tsx,astro}.
  • Must not depend on any non-Google web font.
  • Visible "X skills" / "Y systems" claims must read from getCatalogCounts() — never hardcode. The hero, capabilities cards, labs pills, selected-work fractions, footer Library, and <meta name="description"> all derive from the same call so a fresh content edit can never publish contradictory totals.
  • When the canonical design-templates/open-design-landing/example.html changes, the corresponding section JSX in app/page.tsx and rules in app/globals.css must be updated to match. Those two files are kept in lockstep; the rest of the landing-page sources are not.
  • Content-collection schemas in app/content.config.ts stay loose (passthrough()). Validation lives at render time so vendored upstream Markdown (e.g., guizang-ppt) doesn't break the build when an author uses a slightly different od: key.

Deploy contract (staging → manual production)

Deploys are split across two Cloudflare Pages projects so a merge to main can never publish to the live site on its own:

  • Production project open-design-landingopen-design.ai.
  • Staging project open-design-landing-stagingstaging.open-design.ai.

The safety gate is project separation: only the manual production workflow ever names the production project.

  • .github/workflows/landing-page-staging.yml runs on push to main and deploys to the staging project (open-design-landing-staging, staging.open-design.ai).
  • .github/workflows/landing-page-production.yml is manual (workflow_dispatch) and is the only workflow that names the production project (open-design-landing, open-design.ai). Gate it with required reviewers on the GitHub production environment.
  • .github/workflows/landing-page-ci.yml runs on PRs: it validates the build and, for same-repo branches, deploys a per-PR preview into the staging project (--branch=pr-<number>pr-<number>.open-design-landing-staging.pages.dev) and comments the URL.

The staging workflow triggers when any of these change:

  • apps/landing-page/**
  • design-templates/open-design-landing/**
  • skills/**
  • design-systems/**
  • craft/**
  • templates/**
  • package.json, pnpm-lock.yaml, pnpm-workspace.yaml
  • the workflow files themselves

A push that only edits a SKILL.md MUST trigger the staging workflow — if it doesn't, the paths: filter has drifted from the content-collection glob and the staged site will fall behind silently. Treat that as a regression, not a feature.

Common commands

pnpm --filter @open-design/landing-page dev          # http://127.0.0.1:17574
pnpm --filter @open-design/landing-page typecheck
pnpm --filter @open-design/landing-page previews     # render thumbnails
pnpm --filter @open-design/landing-page build        # static export → out/

When to update this app

  • Added/edited a SKILL.md, DESIGN.md, craft *.md, or live-artifact template at the repo root → no landing-page edit required; CI rebuilds and re-renders thumbnails on the next push to main.
  • Adding a new top-level route group (e.g. /playbooks/) → add an Astro page directory under app/pages/, a content collection in app/content.config.ts, a shaping function in app/_lib/catalog.ts, and route entries that match the existing index/detail/facet pattern.
  • New section added to the canonical landing page → port it into app/page.tsx and app/globals.css keeping lockstep with design-templates/open-design-landing/example.html.
  • Brand re-keying for a non-Open-Design tenant → fork the app, update copy, swap PNGs. Do not parameterize this app for multi-tenancy.