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 tomasterbypass 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
- Normal:
- 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 touchscripts/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/).
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 inSTYLE-GUIDE.md. - TypeScript/JavaScript: Must follow
tsconfig.jsonsettings. 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:Do NOT use Prettier's multi-arg style where name, props, and opts are each on separate indented lines.const r = new SomeResource("name", { prop: value, }, { provider: p, });
- 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/programswith a language suffix in the filename. - Mirror the structure of existing content; do not invent new layouts.
- Docs go under
- 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```mermaidfenced 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/*) underassets/fingerprinted/, mirroring the path you'd use understatic/. The template partials route every<img>throughlayouts/partials/fingerprinted-img.html, which content-hashes filenames, converts rasters to WebP, and generates responsivesrcsets. 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_imageand assets used by non-template layouts can stay instatic/. - Meta images:
meta_imageis optional fordocs,what-is,migrate,partner,topics,events, andblogpages. Leave it blank andscripts/generate-meta-images.mjsproduces an on-brand social card at build time (resolved bylayouts/partials/meta-image-url.html). A page-levelmeta_imagealways 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 PRneeds-designfor a designer-made one); a post's off-brand legacy meta image, if any, was renamed tometa-legacy.pngand shows in a collapsed "Archived feature image" panel. - Spelling/Grammar: Always correct errors. Use American English spelling.
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.
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 stampupdated: YYYY-MM-DD(notlastmod); otherwise route around it with an alias/redirect. See "Dates:updatedvslastmod" 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.
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.
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 fromcontent/community/team/anddata/team/./pricing: the pricing page, built fromapps/www/src/data/pricing/. This repo keeps only the generateddata/pulumi_editions.yamlthat 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 fromapps/www/src/content/topics/.scripts/redirects/topics-redirects.txt301s 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 fromapps/www/src/data/customers/andapps/www/src/content/customers/.scripts/redirects/customers-redirects.txt301s 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 fromapps/www/src/content/section-pages/.scripts/redirects/landing-page-redirects.txt301s 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.
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
glossaryshortcode anddata/glossary.tomlare 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.ymlanddata/header_nav.yamlare synced downstream. marketing-web'sscripts/sync-content.mjsreads 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;
/devalso keeps its own search at/dev/browse.scripts/search/update-search-index.jsfetches/dev/search-index.json(published by pulumi/marketing-web), normalizes each record onto our schema withsection: "Dev Center"(dropping its non-searchablecontentfield), and merges it into the Algolia index. The "Dev Center" tab is wired viadata-facetson the#searchelement inlayouts/partials/docs/menu.html. A Dev Center fetch failure is non-fatal — the publish continues without those records.
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.
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.
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.
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 examplepulumi_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 (checkPulumiCloudFeatureinscripts/lint/lint-markdown.js). An ungated page carries no key. The key names the feature because the value does, and to leavecloud_featurefree 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.jsrelies 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.checkPulumiCloudShortcodevalidates 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.checkPulumiCloudEditionsShortcodevalidates 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: truewhen 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.
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'sid,name,min_edition, andhidden.min_editionalready accounts forrequires:, so nothing in this repo expands availability. - To add or rename a feature, move it to another edition, or set
requires:/hidden:, editfeatures.yamlin 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, ormake lintfails 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.htmlindexes the file for templates, andscripts/lint/lint-markdown.jschecks frontmatter and shortcode ids against it so authors fail inmake lintrather 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 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 indata/blog_categories.yaml(the single source of truth read byscripts/lint/lint-markdown.js). Category is required and singular: every post declares exactly onecategory:scalar value. Use the best-fitting specific kind, orgeneral(the default) for posts that don't fit cleanly (e.g. SEO comparisons or "what is X" explainers — those rely on tags instead).make lintfails 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, editdata/blog_categories.yamlin a PR and raise it in #blogs. The blog docs-review additionally flags posts that landed in a specific kind but really belong ingeneral(and vice versa).tags— the topical axis (clouds, languages, products, scenarios). Curated-but-open, not build-enforced. Reuse a tag from the canonical vocabulary indata/blog_tags.yamland avoid near-duplicates (kubernetesnotk8s,infrastructure-as-codenotiac,pulumi-cloudnotpulumi-service,dotnetnotc#/.net). Tags are lowercase and hyphen-delimited.series— the optional reading-path axis. A post joins a series with a singleseries: <slug>scalar key, where the slug is defined indata/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 totags—make lintfails on a series slug used as a tag, and on aseries: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.
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.
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.
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.
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.
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.mdthere. 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: snowflakein 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 andlayouts/partials/customers/href.html. A card naming a customer with nocase_studyfails the build. - Link to a case study with a plain href (
/customers/<slug>/). The pages aren't built here, sorelrefto them fails the build.
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.
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 frontmatterdate:.make lintenforces both (checkChangelogFilenameinscripts/lint/lint-markdown.js) — a mismatch or non-prefixed name is a hard build failure. - Assets in
images/andvideos/must also be date-prefixed asYYYY-MM-DD-<slug>.<ext>(use the referencing entry's date).make lintenforces this too (checkChangelogAssets). Rename the asset and update its reference together. - Create a new entry with the
/new-changelogskill (orhugo new --kind changelog content/releases/changelog/YYYY-MM-DD-<slug>.md, which usesarchetypes/changelog.md). The archetype derivestitleanddatefrom 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 indata/pulumi_editions.yaml(free,essentials,pro,enterprise), enforced bycheckChangelogEditionsinscripts/lint/lint-markdown.js; the templates look the id up and render the display name, so writeenterprise, notEnterprise. 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 bothproandenterprise). The legacytiers:array and singulartier: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.
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).
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:
.btnbutton 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", plusform-textarea/form-select/form-checkbox/form-radioandform-label/form-help/form-error. Control heights mirror the.btnsize scale. Also exposes@mixins (form-control-base, …) for form-consuming partials. .badgesystem (shared/_badge.scss) —class="badge badge-success",layouts/partials/badge.html, or@extend .badge; @extend .badge-<variant>;.- Shared type scale (
shared/_utilities.scss) — theheading-xl/heading-1…heading-6,body-sm…body-2xl, andfont-overline@utilityclasses. 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).
- Inline Tailwind utility classes — including arbitrary values (
bg-[#abc123],w-[42ch],grid-cols-[1fr_auto]). This is the default for one-off styling. - 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@extendon a shared primitive first. - 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).
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.
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-themeTypography 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. Thedark:variant is wired to the docsdata-themeattribute (@custom-variant darkintheme/src/scss/main.scss), sodark: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-ringrather than raw--color-*scales — they flip automatically. For selectors shared with non-docs pages, use thevar(--docs-TOKEN, ORIGINAL)fallback form so light source files stay untouched. - Lean on the automatic flips. There are three.
--color-violet-primaryis re-pointed toviolet-300in the dark block, sotext-violet-primary,bg-violet-primary,border-violet-primary, and anyvar(--color-violet-primary)get dark mode for free (solid.btn-primaryis the exception, and pinsviolet-700).h1–h6andpare flipped on the element itself, since@layer basesets their color directly. And a bareborder/border-tpicks up the dark hairline, because*re-points the preflight default border color. Nothing else is automatic — no other--color-*scale is remapped, sotext-gray-950,bg-white,bg-gray-50, and an explicitborder-gray-200(which beats the*default on specificity) all render identically in both modes and need their owndark: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.htmlpartial (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.scsstint automatically;background:url()colored marks do not.
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.yamlis generated — never hand-edit it. Change the plain-text sources indata/hero_agent_loop.yamland runyarn --cwd theme gen:hero-text. That data file also carriesdark_code, the flag that picks the code panel's treatment (shikimin-darkon a violet-950 panel, ormin-lighton 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 explicitxcomputed from its column. A text change can look right onmake serveand still break on deploy — verify against a--minifybuild, 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 — whatreset()owns, whygsap.killTweensOfis 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-motionrendering, 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.
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.
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, 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.
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.