Skip to content

Latest commit

 

History

History
342 lines (216 loc) · 47.4 KB

AGENTS.md

File metadata and controls

342 lines (216 loc) · 47.4 KB

AGENTS.md — Pulumi website (Hugo): docs, blog, marketing, and example programs


Build / Test / Lint Workflow

Note: For comprehensive details on the build system, deployment infrastructure, and CI/CD workflows, see BUILD-AND-DEPLOY.md. This file is large, so read only specific sections as needed to conserve tokens.

Never push directly to master. Always create a branch and open a PR. Direct pushes to master bypass review and CI checks. (Server-side branch protection is planned but not yet in place.)

Agents must use these exact commands:

  • Install deps: make ensure
  • Build site: make build
  • Serve locally on port 1313 (accessible with curl):
    • Normal: make serve
    • With asset rebuilds: make serve-all
  • Lint: make lint (must pass before commit/merge)
  • Lint prose: make lint-prose (Vale; nags, never blocks. Also surfaces in pinned PR reviews.)
  • Format: make format
  • Run all tests: make test
  • Run the review pipelines' own test suites: make test-review-pipeline (pytest + standalone harnesses + every --self-test; required if you touch scripts/content-review/, scripts/blog-review/, or .claude/commands/docs-review/scripts/)
  • Run specific program test:
    ONLY_TEST="program-name" ./scripts/programs/test.sh
  • Fix trailing spaces:
    sed -i '' 's/[[:space:]]*$//' file1.md file2.md ...

Do not substitute other tools or commands. The root, theme/, and infrastructure/ are Yarn projects, each with its own yarn.lock: install and run packages there with yarn, never npm install or pnpm, and don't change package.json to use another package manager. A stray package-lock.json beside a yarn.lock is a mistake. npm is only for the few directories that deliberately carry their own package-lock.json (e.g. scripts/snippet-sweep/).


Code & Content Rules

For all content files, Pulumi's voice, tone, prose, product naming, and grammar are defined in the Pulumi brand guide (brand.pulumi.com), which is also exposed to agents through the public brand MCP server. Consult the relevant section (voice, writing style, or terminology) before writing or reviewing — the terminology section is the canonical reference for product/feature names and the retired names never to use again. STYLE-GUIDE.md covers only this site's Hugo/repo mechanics (shortcodes, links, navigation, code fences, etc.) and points to the brand guide for each topic it doesn't own. If the brand MCP is unavailable, fall back to the offline Vale mirror in styles/Pulumi/ — which mirrors the same terminology — and say so in your review or PR body rather than working from memory. If a rule is in neither, fall back to the Google Developer Documentation Style Guide. Do not invent new style conventions; ask for clarification if something is ambiguous.

Precedence: wherever the brand guide overlaps with anything in this repo — these conventions, STYLE-GUIDE.md, or any skill (including the social, SEO, and AEO guidance that still lives in this repo), the brand guide takes priority. That specialized guidance stays in the repo for now; if the brand guide later grows its own, the brand guide's version wins.

Meta files like this one, BUILD-AND-DEPLOY.md, and agent instruction/skill files (e.g., .claude/commands/*.md) are exempt from formatting rules (heading case, trailing newlines, etc.).

For all content files (docs, blogs, changelog entries, etc.):

  • Markdown: Must always end with a newline.
  • Headings: Sentence case at every level (H1 included), and sentence case for nav menu labels. See the brand guide's writing style section. Hugo heading mechanics (one H1, FAQ ? exception) live in STYLE-GUIDE.md.
  • TypeScript/JavaScript: Must follow tsconfig.json settings. No comments unless explicitly requested.
  • TypeScript program files (static/programs/): Use hand-written constructor style — resource name and opening { on the same line, }, { inline when an opts argument follows:
    const r = new SomeResource("name", {
        prop: value,
    }, { 
        provider: p,
    });
    Do NOT use Prettier's multi-arg style where name, props, and opts are each on separate indented lines.
  • File Placement:
    • Docs go under content/docs/...
    • Blog posts go under content/blog/...
    • Other content goes into appropriate content/... subdirectory
    • Code examples go under /static/programs with a language suffix in the filename.
    • Mirror the structure of existing content; do not invent new layouts.
  • Includes: Use Hugo shortcodes for shared content, never raw Markdown copy-paste.
  • Naming: Product, feature, and category names — canonical casing, preferred terms, retired names — come from the brand guide's terminology section. Never introduce a name from memory: check the retired-names table first (e.g. it's Pulumi Discovery now, not “Pulumi Insights”; Pulumi Neo, not “Copilot”). Non-proper nouns stay lowercase (“stack,” not “Stack”).
  • Ordered Lists: Every item begins with 1. to minimize diff noise.
  • Diagrams: Prefer Mermaid diagrams over ASCII art. The site renders Mermaid natively via a Hugo code block hook (layouts/_default/_markup/render-codeblock-mermaid.html). Use ```mermaid fenced code blocks. See Mermaid docs for syntax.
  • Images on template-driven pages: Place new images for template-driven pages (homepage, product pages, event pages, case studies — anything rendered through layouts/partials/template-partials/*) under assets/fingerprinted/, mirroring the path you'd use under static/. The template partials route every <img> through layouts/partials/fingerprinted-img.html, which content-hashes filenames, converts rasters to WebP, and generates responsive srcsets. Frontmatter paths still look like /images/foo.svg; the partial resolves them. Missing assets cause a build panic, so there is no silent fallback. meta_image and assets used by non-template layouts can stay in static/.
  • Meta images: meta_image is optional for docs, what-is, migrate, partner, topics, events, and blog pages. Leave it blank and scripts/generate-meta-images.mjs produces an on-brand social card at build time (resolved by layouts/partials/meta-image-url.html). A page-level meta_image always wins, but custom overrides are discouraged — the generated card covers virtually every case and stays on-brand automatically. For blog posts the card is built from the post title + feature_image (generate the feature image with /blog-feature-image, or label the PR needs-design for a designer-made one); a post's off-brand legacy meta image, if any, was renamed to meta-legacy.png and shows in a collapsed "Archived feature image" panel.
  • Spelling/Grammar: Always correct errors. Use American English spelling.

Moving and Deleting Files

⚠️ SEO CRITICAL: Missing aliases on moved files break search rankings and external links.

Use the /move-doc skill for Hugo content files — it handles git mv, alias injection, link updates, and verification. For non-Hugo files (generated content, static assets), add S3 redirects in /scripts/redirects/ (format: source-path|destination-url, place entries in topic-appropriate files). Manual move procedure and anchor-link caveats: see .claude/commands/move-doc/SKILL.md.


Updating Internal Links

When moving documentation, aliases handle redirects automatically. Update internal links strategically:

  • DO update links in /content/docs/ and /content/product/.
  • /content/blog/ is historical — swap a broken link only for an equivalent replacement, and when the change is worth surfacing to readers stamp updated: YYYY-MM-DD (not lastmod); otherwise route around it with an alias/redirect. See "Dates: updated vs lastmod" below.
  • Link style: links within /docs/ must use the full canonical path (e.g. /docs/iac/concepts/stacks/). Never use parent-directory references (../stacks/) — they break when files move.

For find/sed implementation patterns, see .claude/commands/move-doc/SKILL.md.


Navigation and llms.txt

The left nav is data-driven from data/docs_menu_sections.yml, which is consumed by layouts/partials/docs/menu.html (the rendered nav), layouts/index.llms.txt (the curated /llms.txt index), and layouts/partials/llm-sitemap-walk.json (the /docs/llm-sitemap.json machine-readable sitemap). When you add, remove, or reorder top-level nav sections, all three flow through automatically. Per-section descriptions in /llms.txt come from each landing page's meta_desc front-matter — edit the page if you need to change how it reads in the index.


Pages that live in pulumi/marketing-web

Some pulumi.com pages ship from pulumi/marketing-web (apps/www, an Astro build with its own S3 bucket and CloudFront distribution). infrastructure/index.ts proxies the paths in infrastructure/marketingWebPaths.ts to that distribution, and invalidate-marketing-web-cache.yml flushes them from CloudFront when marketing-web deploys. To change one of these pages, edit it there:

  • /dev: the Dev Center (tutorials, templates, community examples, and the glossary). See below.
  • /community: the community landing page and /community/puluminaries/. /community/team/* (the author pages blog bylines link to) is still built here from content/community/team/ and data/team/.
  • /pricing: the pricing page, built from apps/www/src/data/pricing/. This repo keeps only the generated data/pulumi_editions.yaml that docs callouts read (see "Edition data" below). Redirects under /pricing/ still come from S3.
  • /up: the getting-started wizard.
  • /topics: the explainer section that replaced /what-is, built from apps/www/src/content/topics/. scripts/redirects/topics-redirects.txt 301s the old /what-is/ URLs (and the legacy URLs those pages carried) straight to their /topics/ pages.
  • /customers: the customer board, its industry pages, and every case study, built from apps/www/src/data/customers/ and apps/www/src/content/customers/. scripts/redirects/customers-redirects.txt 301s the old /case-studies/ URLs. This repo keeps a frozen copy of the registry for the pages that still show customers (see "Customers" below).
  • /pulumi-for-startups, /extend-trial, /confirmation: landing pages built from apps/www/src/content/section-pages/. scripts/redirects/landing-page-redirects.txt 301s the old /microsoft-for-startups/ alias. HubSpot workshop forms redirect to /confirmation/workshop/ and the Pulumi Cloud trial banner links to /extend-trial/?org=…&email=…, so neither URL can change.

When another page moves over, add its paths to infrastructure/marketingWebPaths.ts and a bullet to this list. marketing-web's sitemap at /sitemap-www-index.xml (listed in layouts/robots.txt) lists every page it builds that isn't noindex, so ship the proxy path in the same release as the page.

The Dev Center

Hugo's content/tutorials/ and content/templates/ trees were deleted when the Dev Center launched; scripts/redirects/dev-redirects.txt 301s their URLs into /dev.

What that means when you work here:

  • Don't add tutorial or template content to this repo. A new tutorial, a new template page, or a glossary term goes to pulumi/marketing-web. (The glossary shortcode and data/glossary.toml are a different, docs-only glossary rendered at /docs/glossary/ — that one stays.)
  • Link to stable Dev Center pages, not individual tutorials. Prefer facet landing pages (/dev/browse/cloud/aws/), filter URLs (/dev/browse/?cloud=aws&tag=containers), hubs, and template groups (/dev/templates/<group>/[<cloud>/]). Link to a single /dev/tutorials/<slug>/ only when it's the page's hands-on companion; tutorials can be retired in marketing-web without this repo knowing. Full rules: STYLE-GUIDE.md §Linking to the Dev Center. Never /tutorials/ or /templates/; those only redirect.
  • data/footer.yml and data/header_nav.yaml are synced downstream. marketing-web's scripts/sync-content.mjs reads both, so a nav or footer edit here also changes the Dev Center's chrome. Both carry one Dev Center entry pointing at /dev/; the Tutorials, Templates, and Pulumi guides entries collapsed into it.
  • Docs search includes the Dev Center as a facet. Docs search indexes this repo, the Registry, and the Dev Center; /dev also keeps its own search at /dev/browse. scripts/search/update-search-index.js fetches /dev/search-index.json (published by pulumi/marketing-web), normalizes each record onto our schema with section: "Dev Center" (dropping its non-searchable content field), and merges it into the Algolia index. The "Dev Center" tab is wired via data-facets on the #search element in layouts/partials/docs/menu.html. A Dev Center fetch failure is non-fatal — the publish continues without those records.

Neutral tone in docs

Docs that discuss other products, especially comparison pages (content/docs/iac/comparisons/, content/docs/esc/vs/), must inform rather than persuade. Follow STYLE-GUIDE.md §Neutral tone toward other products when writing or reviewing any docs page that mentions another product.


AI and agent positioning

Pulumi supports the full spectrum of AI agents, and content must never present Neo as the only way to use AI with Pulumi or frame Neo as an either-or choice against other coding agents.

  • Docs (content/docs/, content/what-is/): community-centric and balanced. Third-party coding agents (Claude Code, Codex, Cursor, GitHub Copilot, etc.) working with Pulumi — through IaC, Agent Skills, and the Pulumi MCP server — are first-class. Neo is Pulumi's purpose-built infrastructure agent: the deepest integration and the fastest path to a great infrastructure agent out of the box, but one option on a spectrum, and most teams benefit from using both.
  • Product/marketing pages (content/product/, homepage): may lead with Neo and sell it hard, but should still acknowledge that Pulumi's code-first approach works with the agent a reader already uses. Avoid copy that disparages other agents (e.g. "unlike generic AI tools").
  • When listing agent options (e.g. in migration guides), follow the pattern in content/docs/iac/guides/migration/migrating-to-pulumi/from-terraform.md: list Neo alongside Claude Code, Cursor, and Codex as equally legitimate choices, with at most a light note on Neo's built-in advantage.

Resource options

The reference pages under content/docs/iac/concepts/resources/options/ show a classification callout (custom resource / component resource / both, plus per-SDK enforcement) rendered by the resource-option-scope shortcode. The classification data — and the summary table on that section's _index.md — is generated from data/resource_options.yaml, which is the single source of truth. When you add a new resource option, you must add an entry to data/resource_options.yaml and place the {{< resource-option-scope "<name>" >}} shortcode on the new page. That file's header comment is the authoritative step-by-step checklist; the build fails if a page references an option missing from the data file.


Pulumi Cloud availability markers

Docs pages state which Pulumi Cloud edition a feature needs through a generated violet callout, not hand-written prose. A marker names a feature, never an edition — the edition the callout states is derived from that feature's availability in data/pulumi_editions.yaml (see "Edition data" below), so a feature that moves editions is a one-line edit that updates /pricing/ and every marked page at once.

  • Whole page: add pulumi_cloud_feature: <feature-id> to the front matter (for example pulumi_cloud_feature: rbac). layouts/docs/{single,list}.{html,md} renders the callout above the content. We only mark what a reader has to buy, so an unknown id, an edition id, true, false, and a feature that's available on the Free edition are all hard lint failures (checkPulumiCloudFeature in scripts/lint/lint-markdown.js). An ungated page carries no key. The key names the feature because the value does, and to leave cloud_feature free for the cloud provider sense the word carries elsewhere.
  • One section: put {{< pulumi-cloud "<feature-id>" />}} on the line directly after the heading it applies to. scripts/search/page.js relies on that adjacency to skip the callout when it builds a heading's search snippet. The no-argument form {{< pulumi-cloud />}} means "Pulumi Cloud, all editions" and is only for mixed pages where the reader can't otherwise tell a section needs Cloud at all; a block form with inner content renders orientation prose in the same box. When a section covers something broadly and only one option in it is gated, add "named" as a second argument ({{< pulumi-cloud "customer-managed-runners" "named" />}}) so the callout leads with the feature's name from the data file ("Customer-managed deployment runners is a Pulumi Cloud feature available in …") instead of "This feature". The pricing data is the source of truth for feature names: make the page's prose use that name, rather than renaming the feature to match the docs. checkPulumiCloudShortcode validates the argument against the same vocabulary as the front matter key.
  • Inline, where a callout doesn't fit (a table cell, a per-edition list): {{< pulumi-cloud-editions "<feature-id>" >}} renders the editions a feature is available in ("Pro, Enterprise", or "All editions"), from the same data. Unlike a marker, it accepts a feature available on Free. checkPulumiCloudEditionsShortcode validates the id.
  • If the feature isn't in the data file yet, add it in pulumi/marketing-web first and merge the sync PR that brings it here. Give it hidden: true when it isn't a marketed line item on /pricing/ (gated deployments, ESC change requests, organization templates): hidden features stay out of the comparison table but remain resolvable by id.
  • Don't say it twice. When you add a marker, delete the hand-written sentence or note that lists the required editions. The callout already links to /pricing/. Keep only prose that says something the callout doesn't, such as a per-edition limit.
  • The V6 editions are Free, Essentials, Pro, and Enterprise. Use those names for V6 product guidance. Preserve V5 edition names when content specifically describes existing V5 customers. "Pro" was also a pre-2023 legacy plan; say "the legacy Pro plan" when you mean that one.

Edition data

data/pulumi_editions.yaml is generated — never hand-edit it. pulumi/marketing-web owns what Pulumi sells (the editions, their prices and cards, and the per-feature availability matrix) in apps/www/src/data/pricing/, builds /pricing/ from it, and opens a chore/sync-pulumi-editions PR here whenever the editions or availability change. Everything here that names an edition reads the generated copy: the docs availability markers above, {{< pulumi-cloud-editions >}}, the changelog editions: badges, /releases/index.json, and /llms.txt.

  • It carries only what docs need: the ordered editions (id, name) and each feature's id, name, min_edition, and hidden. min_edition already accounts for requires:, so nothing in this repo expands availability.
  • To add or rename a feature, move it to another edition, or set requires:/hidden:, edit features.yaml in marketing-web; its header comment covers the vocabulary ("edition", never "plan"/"tier"/"subscription") and the availability rules. Merge the resulting sync PR here before a docs page that uses the new id, or make lint fails on the unknown id.
  • Validation is split by repo. The data invariants (unique ids, monotone availability) run in marketing-web. Here, layouts/partials/pricing/data.html indexes the file for templates, and scripts/lint/lint-markdown.js checks frontmatter and shortcode ids against it so authors fail in make lint rather than in a full Hugo build.

Callout markup for all callout types (info, tip, warning, cloud, and the GitHub-alert types) comes from the shared layouts/partials/notes.html. The {{% notes %}} shortcode takes a named type argument only — {{% notes "warning" %}} is silently ignored and renders an info box, so always write {{% notes type="warning" %}}. Adding a new callout type means adding it to the $icons dict in that partial and adding a &.note-<type> block to both theme/src/scss/_notes.scss and theme/src/scss/docs/_docs-theme.scss — dark mode is not automatic.

Callouts inside a list item: use the {{< notes >}} form, indented to the item's continuation column. {{% shortcode output is spliced back into the markdown source before it is rendered, so an indented {{% notes %}} always lands at column 0 and splits the list in two (restarting the numbering); {{< is substituted after the markdown pass, so the callout stays inside the <li>. The shortcode strips the body's common indentation either way, so indent the body to match the tags.


Blog categories and tags

Blog posts carry three taxonomy axes (category and tags are always present; series is optional):

  • category — the kind of post. This is a closed set defined in data/blog_categories.yaml (the single source of truth read by scripts/lint/lint-markdown.js). Category is required and singular: every post declares exactly one category: scalar value. Use the best-fitting specific kind, or general (the default) for posts that don't fit cleanly (e.g. SEO comparisons or "what is X" explainers — those rely on tags instead). make lint fails on a missing value, a list value, or a value outside the set. Do not invent categories — pick an id from the data file. To add/rename one, edit data/blog_categories.yaml in a PR and raise it in #blogs. The blog docs-review additionally flags posts that landed in a specific kind but really belong in general (and vice versa).
  • tags — the topical axis (clouds, languages, products, scenarios). Curated-but-open, not build-enforced. Reuse a tag from the canonical vocabulary in data/blog_tags.yaml and avoid near-duplicates (kubernetes not k8s, infrastructure-as-code not iac, pulumi-cloud not pulumi-service, dotnet not c#/.net). Tags are lowercase and hyphen-delimited.
  • series — the optional reading-path axis. A post joins a series with a single series: <slug> scalar key, where the slug is defined in data/blog_series.yml. Series are their own taxonomy: term pages render at /blog/series/<slug>/ and the directory at /blog/series/. Do not also add the slug to tags — make lint fails on a series slug used as a tag, and on a series: value that isn't defined in the data file.

See BLOGGING.md for the author-facing version of these rules.

Per-post optional front matter beyond the taxonomy axes — resource_links (icon links at the foot of the post), related_posts (pinned related slugs), author_roles, and updated — is documented in BLOGGING.md. The blog homepage is curated separately in data/blog_home.yaml (featured = the four hero/featured slots; featured_series = the "Popular series" strip); that file's header comment is the authoritative reference.

Embedding events and posts in a post body

To promote an event or another post from inside a post, embed its card with {{< blog/card "/events/<slug>/" >}} — do not hand-write a blog/cta-card that restates the title, date, and blurb. Those copies rot: the shortcode renders the same tiles as /events/ and the blog homepage, derived entirely from the target page, so an event card picks up a retitled session or a newly added recording (Register → Watch) on its own. A card is always full width, so it takes exactly one path — several in a row is several shortcodes — and the path is its only parameter: there is no title or body copy to set. Reserve blog/cta-card for destinations that have no card (docs, product pages, signup) and for generic get-started asks.

Card layouts live in layouts/partials/blog/card/ — medium (grid tile), contained (medium boxed in a .card), wide (text left, square image right; the homepage feed's card view and in-body embeds), small, featured, series, list-row. Reuse one; don't clone its markup into a new partial. wide renders only its wrapper's contents and takes the wrapper's classes as a param, because the homepage row starts hidden (the view toggle flips it to flex) while an embed is a boxed .card.

Images and videos in a post body

Post-body images and {{< video >}} clips open in a lightbox when — and only when — enlarging them would show the reader more. theme/src/ts/blog-lightbox.ts measures each one in .blog-post-content against the viewport (intrinsic size vs. rendered size vs. the size the overlay could draw it at) and wraps the ones that qualify in a trigger button; the overlay shell is layouts/partials/blog/lightbox.html, rendered by layouts/blog/single.html. Nothing about this is author-driven: do not add a wrapping link, a figure, or a per-image opt-in to make something clickable, and don't hand-roll a second modal. The escape hatch runs the other way — data-no-lightbox on the element or any ancestor keeps it plain. A post-body video that already has controls is skipped entirely: a wrapping button would swallow clicks meant for the control bar, and those controls already offer fullscreen. (The overlay's own copy of a video always has controls, and is exempt from click-to-close for the same reason.)

The thresholds live at the top of the TypeScript file with the reasoning for each; tune them there rather than special-casing a post. Two of them, OVERLAY_MARGIN_X/OVERLAY_MARGIN_Y, mirror the overlay's padding and its caption budget — if you change the spacing in lightbox.html, change them to match, or the script will promise an enlargement the overlay can't deliver.

Dates: updated vs lastmod

When you revise an existing blog post, use updated: YYYY-MM-DD — not lastmod. This is the established convention (the vast majority of revised posts use it) and the one wired to the UI: layouts/blog/single.html renders .Params.updated as the visible "Updated <date>" line beside the publish date. Leave the original date unchanged; set updated to the revision date. It's the same field documented in BLOGGING.md.

Do not reach for lastmod. It's a Hugo built-in that only feeds the sitemap and schema.org dateModified, and the site already sets enableGitInfo: true (config/_default/config.yml), so Hugo derives .Lastmod from the commit date automatically. A hand-stamped lastmod is therefore invisible to readers and redundant with git. It's easy to default to because lastmod is the generic Hugo idiom for "last changed" — but on this site the reader-facing, canonical field is updated.

Blog known-issues index (automated daily review)

A scheduled workflow (.github/workflows/blog-review-index.yml) reviews a few existing blog posts per day — selected deterministically by scripts/blog-review/select-posts.py (traffic/GSC-weighted staleness; oldest-unreviewed-first until the blog data exports ship) — and records structured findings (dead links, factual rot, deprecated products, thin content) into an S3 known-issues index. It is flag-only state, not content: nothing is committed to the repo, no fixes are applied, and no PRs are opened. State lives in the content-review ledger bucket under the blog-review/ prefix (ledger/, index/, runs/, and index/_summary.json); the on/off/cadence switch is the BLOG_REVIEW_COUNT repo variable (unset = 5 posts/run, '0' = off). The review skill is .claude/commands/blog-review-index/SKILL.md; its closed issue taxonomy lives in that skill's references/issue-taxonomy.md and is enforced by scripts/blog-review/validate-findings.py. The index is evidence for a future, human-reviewed process that marks rotted, low-value posts block_external_search_index: true — do not add that frontmatter based on the index without going through that process.


Customers

pulumi/marketing-web owns the customer registry and builds /customers/ (see "Pages that live in pulumi/marketing-web" above). data/customers.yaml, data/customers_industries.yaml, and the logos in assets/fingerprinted/logos/customers/ are a frozen, read-only copy for the Hugo pages here that still show customers: the homepage logo carousel and case-study cards, product pages, gads pages, /enterprise, /solutions, the logo banners and walls, and the testimonial sections. Every one of them reads it through layouts/partials/customers/data.html. Nothing syncs it.

  • Add or change a customer in marketing-web first, following apps/www/docs/customers.md there. Copy the entry and both logo files here only when a page built here needs it.
  • Reference a customer by id, never by path. {{ partial "customer-logo.html" (dict "customer" "snowflake") }}, customer: snowflake in a testimonial or case-study card, logos: [snowflake, bmw] in a carousel.
  • Case-study links and quotes come from the registry. An entry's case_study: block (href, title, stat, stat_label, quote, attribution) feeds the case-study cards and grids and layouts/partials/customers/href.html. A card naming a customer with no case_study fails the build.
  • Link to a case study with a plain href (/customers/<slug>/). The pages aren't built here, so relref to them fails the build.

Events

Event pages live at content/events/<slug>/index.md — a bundle whose content is entirely frontmatter; the schema's source of truth is archetypes/event/index.md (its comments are kept current). Create a new event with the /create-event skill (.claude/commands/create-event/SKILL.md): it collects details from the prompt, the Linear tracking issue, or an interactive wizard, scaffolds the bundle, generates social cards via /event-meta-image, opens the docs PR, and keeps the tracking issue current. Events are tracked in Linear (Marketing team, the quarter's Workshops (Q<n> <yy>) project), not in pulumi/marketing GitHub issues: each event is a parent issue, usually synced from the Notion workshop schedule, with its promotion checklist as sub-issues. The skill creates one from the blueprint (MA-1045) only when none exists. It supports --dry-run (writes PR/Linear previews instead of touching GitHub or Linear) and delegates execution to the event-creator subagent (.claude/agents/event-creator.md). HubSpot form and Salesforce campaign IDs come back from marketing on the Linear issue or as a review suggestion on the PR — never invent them; a gated page carries TODO placeholders until they land, then a rerun of /create-event <MA-n> wires them in.


Releases changelog entries

Individual changelog items live in content/releases/changelog/ — one markdown file per entry, listed by month on /releases/ and rendered at /releases/changelog/<slug>/ (layouts/changelog/single.html). Shared images/videos live in the images/ and videos/ subfolders and are referenced by absolute path (e.g. /releases/changelog/images/2026-06-18-foo.png), so entry renames don't affect them.

  • Filenames must be YYYY-MM-DD-<slug>.md, and the date prefix must match the frontmatter date:. make lint enforces both (checkChangelogFilename in scripts/lint/lint-markdown.js) — a mismatch or non-prefixed name is a hard build failure.
  • Assets in images/ and videos/ must also be date-prefixed as YYYY-MM-DD-<slug>.<ext> (use the referencing entry's date). make lint enforces this too (checkChangelogAssets). Rename the asset and update its reference together.
  • Create a new entry with the /new-changelog skill (or hugo new --kind changelog content/releases/changelog/YYYY-MM-DD-<slug>.md, which uses archetypes/changelog.md). The archetype derives title and date from the filename.
  • Optional editions: is a YAML array marking Pulumi Cloud edition availability, rendered as badge(s) beside the date. Values are edition ids from the closed set in data/pulumi_editions.yaml (free, essentials, pro, enterprise), enforced by checkChangelogEditions in scripts/lint/lint-markdown.js; the templates look the id up and render the display name, so write enterprise, not Enterprise. List every edition the feature is available in — since a lower edition implies the ones above it, that means the lowest applicable edition and all editions above it (e.g. a Pro feature lists both pro and enterprise). The legacy tiers: array and singular tier: scalar are hard build failures: "tier" isn't a word the product uses, and the old list predates editions entirely.
  • Renaming an entry (changing its slug) changes its URL, so add an aliases: entry pointing at the old /releases/changelog/<old-slug>/ path — same SEO rule as moving any content file.

Styling (CSS / SCSS / Tailwind)

The theme uses Tailwind v4 (configured in CSS, no tailwind.config.js) across two SCSS bundles: theme/src/scss/main.scss (docs/app) and theme/src/scss/_marketing.scss (marketing).

Reuse the shared system first

Before writing new component CSS, use the shared design-system primitives in theme/src/scss/shared/ (see shared/README.md). Don't reinvent a button, card, badge, or heading:

  • .btn button system (shared/_button.scss) — class="btn btn-primary", plus variants (outline, secondary, ghost, ghost-primary, destructive, link), sizes (btn-sm/btn-lg/btn-icon…), and .btn-split/.btn-group. The file header documents the full compose API.
  • .card / .card-hover (shared/_card.scss).
  • Form system (shared/_forms.scss) — class="form-input form-input-lg", plus form-textarea/form-select/form-checkbox/form-radio and form-label/form-help/form-error. Control heights mirror the .btn size scale. Also exposes @mixins (form-control-base, …) for form-consuming partials.
  • .badge system (shared/_badge.scss) — class="badge badge-success", layouts/partials/badge.html, or @extend .badge; @extend .badge-<variant>;.
  • Shared type scale (shared/_utilities.scss) — the heading-xl/heading-1…heading-6, body-sm…body-2xl, and font-overline @utility classes. Use these instead of hand-rolling font-size/weight/tracking.

Compose them in markup, or in SCSS via @apply/@extend (prefer @extending a primitive over re-@applying its utilities).

Order of preference for authoring styles

  1. Inline Tailwind utility classes — including arbitrary values (bg-[#abc123], w-[42ch], grid-cols-[1fr_auto]). This is the default for one-off styling.
  2. SCSS with Tailwind @apply / @extend — only when inline classes can't stay DRY (the same cluster of utilities repeated across many elements or templates). Reach for @extend on a shared primitive first.
  3. Raw CSS / SCSS — last resort, for what Tailwind genuinely can't express.

The Dark mode section below applies these same rules to /docs theming (dark: variants and --docs-* tokens).


Dark mode (/docs)

The /docs section supports a light/dark/system theme toggle. Dark is light-first: light is the baseline (unchanged from before) and dark is a pure override. The whole system lives in theme/src/scss/docs/_docs-theme.scss (read its header comment first) and is driven by semantic --docs-* tokens defined on body.section-docs and re-pointed under html[data-theme="dark"]. It is scoped entirely to docs pages; nothing here can affect a non-docs page.

Design tokens (colors)

Brand color hex values come from @pulumi/design-tokens (tokens/core/primitives.json, palette-semantics.json). The Hugo theme translates JSON to Tailwind v4 CSS variables — do not edit generated files by hand.

File Role
theme/scripts/build-color-theme.mjs Reads design-tokens JSON, writes theme/src/generated/tailwind-v4/_theme.scss
theme/src/scss/_theme.scss Imports generated palette + docs-specific tokens (breakpoints, docs-* colors)
theme/src/scss/docs/_docs-theme.scss Docs light/dark semantic overrides (--docs-bg, --docs-fg, etc.)

Regenerate after bumping @pulumi/design-tokens:

cd theme && yarn install && yarn build:color-theme

Typography in the generated theme block is local for now — not yet sourced from design-tokens JSON. See the design-system repo AGENTS.md for the full token index.

You must test both modes whenever you add or restyle a visible element on a docs page — new partials, shortcodes, cards, callouts, buttons, icons, or any markup that introduces its own colors, backgrounds, borders, or images. Toggle dark mode (theme switcher at the bottom of the docs sidebar) and confirm the element is legible and on-brand in both. Pure content changes (prose, code samples, frontmatter, links) are safe and don't need a dark-mode pass.

When something needs dark-mode work, prefer the existing levers over hand-written one-off colors:

  • Use Tailwind dark: variants. The dark: variant is wired to the docs data-theme attribute (@custom-variant dark in theme/src/scss/main.scss), so dark:bg-gray-900, dark:text-white, etc. work directly in templates and are automatically scoped to /docs. This is the most direct way to dark-style a new element.
  • Use the semantic tokens. Paint with var(--docs-fg), --docs-fg-muted, --docs-bg, --docs-bg-alt, --docs-surface, --docs-border, --docs-card, --docs-link, --docs-ring rather than raw --color-* scales — they flip automatically. For selectors shared with non-docs pages, use the var(--docs-TOKEN, ORIGINAL) fallback form so light source files stay untouched.
  • Lean on the automatic flips. There are three. --color-violet-primary is re-pointed to violet-300 in the dark block, so text-violet-primary, bg-violet-primary, border-violet-primary, and any var(--color-violet-primary) get dark mode for free (solid .btn-primary is the exception, and pins violet-700). h1–h6 and p are flipped on the element itself, since @layer base sets their color directly. And a bare border / border-t picks up the dark hairline, because * re-points the preflight default border color. Nothing else is automatic — no other --color-* scale is remapped, so text-gray-950, bg-white, bg-gray-50, and an explicit border-gray-200 (which beats the * default on specificity) all render identically in both modes and need their own dark: variant. Surfaces styled via Tailwind @apply (e.g. content .btn-* variants) carry no literal class either, and need their own dark override in _docs-theme.scss.
  • Theme-aware images: use the layouts/partials/docs-logo.html partial (light asset + optional -on-dark.svg), not a bare <img>, for any logo/mark whose colors don't read on a dark background. Masked icons in _icons.scss tint automatically; background:url() colored marks do not.

Homepage hero animation

The homepage hero is one GSAP timeline driving a single inline SVG. It spans four files, and a change to any one of them usually needs a look at the others:

File Role
layouts/partials/template-partials/hero-animation/agent-loop.html the scene, authored in its finished state
theme/src/ts/hero-animation.ts the timeline — read its header comment before editing
data/hero_agent_loop.yaml + theme/scripts/gen-hero-text.mjs the code samples and the pulumi up stream
theme/src/scss/marketing/_hero-animation.scss the CSS-driven spinner, and the pending/idle states
  • data/hero_agent_loop_generated.yaml is generated — never hand-edit it. Change the plain-text sources in data/hero_agent_loop.yaml and run yarn --cwd theme gen:hero-text. That data file also carries dark_code, the flag that picks the code panel's treatment (shiki min-dark on a violet-950 panel, or min-light on the translucent lavender one); the generator copies it into the generated file and the partial paints the panel from it, so the syntax colors and their background can't disagree.
  • Text layout is geometry, not space glyphs. The production build minifies HTML, which collapses whitespace inside <tspan>s, so every segment is anchored by an explicit x computed from its column. A text change can look right on make serve and still break on deploy — verify against a --minify build, not the dev server.
  • The timeline loops, so verify passes 2 and 3. Every loop-state bug this animation has had rendered a flawless first pass. hero-animation.ts's header documents the loop contract — what reset() owns, why gsap.killTweensOf is dangerous here — and how to step the timeline headlessly, which is the only reliable way to see a later pass.
  • The static markup is the prefers-reduced-motion rendering, so it has to stay a correct finished frame. Geometry comes verbatim from the design's storyboard exports; take new values from those rather than hand-editing paths.

Workflow Skills

Before starting any documentation task, check .claude/commands/ for a relevant skill — there are well-structured skills covering common tasks like creating docs, reviewing PRs (see .claude/commands/docs-review/SKILL.md), moving files, and more. To see a full inventory, run .claude/commands/docs-tools/scripts/scrape-metadata.py.

Non-Claude agents: If the user runs a slash command or issues a short command that could be a skill name (e.g., fix-issue, new-doc), look for a matching file in .claude/commands/ to guide your actions.


PR Lifecycle for AI-Assisted Contributions

Open as draft, mark ready when done. Each ready-transition fires one full review; thrashing draft → ready → draft burns budget. Leave AI authoring trailers in commits (Co-Authored-By: Claude ...) — stripping them is bad form and changes nothing about which review runs. Don't delete <!-- CLAUDE_REVIEW N/M --> comments — the re-entrant pipeline edits them in place. A small push that only touches lines carrying outstanding findings refreshes the stale review automatically; otherwise, comment /update-review (fix-response / dispute / re-verify) or transition through draft and back to ready. Review commands are slash commands at the start of a line (/update-review, /new-review, /social-review, and /oversized-ack in an approval; reference: CONTRIBUTING.md §Review commands). @claude is for ad-hoc help, never for refreshing the review, and the retired @claude … #update-review shape runs nothing.

Shipping a PR includes finishing its review. A PR is done when every finding the pre-merge review raised — 🚨 blockers, ⚠️ low-confidence findings, and ✏️ style suggestions alike — has been fixed, refuted, deferred to a filed issue, or explicitly accepted with a stated reason. Clearing 🚨 and stopping is not a shortcut; it is the failure mode the pipeline's own outcome telemetry measures (ignored_low_confidence in scrape-review-outcomes.py).

Agents: treat this as expected behavior on every PR you open here, asked for or not. Use the /address-review skill (.claude/commands/address-review/SKILL.md) — offer to watch for the review when the PR goes ready for review, walk every finding with the user when it lands, and say so plainly (once, then drop it) when a merge would leave items undecided. python3 .claude/commands/docs-review/scripts/review-worklist.py --pr <N> --state .review-worklist-<N>.json --require-clean is the machine answer to "is anything still open?"

The v3 surface (on repo-wide): REVIEW_V3_COMMENTS is set for pulumi/docs, so every fresh review renders v3 cards; a PR reviewed before the flip keeps its legacy monolith until someone comments /new-review, since /update-review refreshes v2 in place by design. On v3 the monolithic pinned review becomes an author card (<!-- CLAUDE_REVIEW_AUTHOR -->, blocking findings with stable F<n> ids + a machine-read REVIEW_STATE disposition block) plus a reviewer brief (<!-- CLAUDE_REVIEW_BRIEF -->), with the verification trail on a linked evidence page and S3 as the system of record. Findings are answered by pushing a fix or replying /update-review <reasoning>; the Sentinel commit status is the single merge gate (findings answered · approved by any human with write access, per approval: in .github/review-routing.yml — every PR still routes to a team, including bot-authored ones, and that team is who gets asked; a bot's approval counts only on an automated lane bot_approvers: names; mechanical skips the model review, never the approver · a staging deploy for changes to the deploy itself, per that file's staging_evidence.paths), with review:waived as the logged break-glass. Deterministic machinery lives in scripts/review-v3/ (see its README) and is covered by make test-review-pipeline.

Review mode vs fix mode. The pre-step artifacts are shaped for a human reading a comment, and a consumer that applies edits instead of posting them needs different settings. vale-findings-filter.py is the live example: by default it caps findings at 10 per file / 50 total and de-duplicates advisory findings per (category, message), all so the pinned review stays readable. Those are a comment budget, not an analysis budget. A lane that fixes rather than flags must pass --fix-mode (today: content-review-article.yml, both the content-review and glow-up lanes) or it silently clears the first ten findings and leaves the rest to resurface as churn on the next review. The dedup fails a fixer the same way: a rule whose message names a shape rather than a term (Pulumi.NarrativeWe — one message for every "we will" in the file) collapses to one line and hides the other occurrences. If you add a consumer that applies findings, check every artifact it reads for the same distinction before assuming the defaults fit.

Only the glow-up lane may iterate to a fixpoint. A Vale fix can create a Vale finding: the rules are independent, so a phrase one rule pushes you toward can be a phrase another rule flags, and a single pass then ships a page the next review immediately re-flags. (The known instance — write-good.TooWordy steering "a number of" into write-good.Weasel's "several" — is fixed at the source: Weasel's quantifier tokens were removed. The loop stays, because nothing stops the next such pair, and it surfaces non-convergence instead of shipping it.) The glow-up lane re-runs Vale after applying (bounded at 3 rounds; see that skill), because verify-glowup-scope.py bounds a rehab by path, size, and protected frontmatter. The fix lane must not: verify-fix-scope.py draws its allowed ranges from the immutable pre-model review-snapshot and matches pre-fix line numbers, so a fix traceable only to a later round reads as an out-of-scope edit and fails the publish gate.

For the full mechanics — refresh-pattern details, short-circuit thresholds, classifier internals — see CONTRIBUTING.md §AI-assisted contributions.


WorkPrentice

Applies only to WorkPrentice — the workprentice GitHub App (workprentice[bot] / app/workprentice), an autonomous agent identity. Every other agent: skip this section, it isn't about you.

WorkPrentice: read WORKPRENTICE.md in the repo root and follow it before opening a PR here. It's additive to this file, not a replacement.