Open new PRs as drafts while you iterate. Automated review (style, accuracy, fact-check) fires only when you mark a PR ready for review, so a draft-first flow:
- Keeps your branch out of the noisy "every push triggers a review" loop.
- Lets you push iteratively without spamming the PR with new comments each time.
- Means the eventual review reflects your finished thinking, not a half-finished commit.
While you're iterating, consider running /docs-review locally — it runs the same style/accuracy pipeline as the automated bot, but stays in your conversation and never posts to GitHub. Catching findings here is cheaper than catching them after the pinned review fires.
When you're ready, use the Ready for review button on the PR page. Triage runs again to refresh labels, then the full review fires once and pins its findings to a single comment at the top of the PR. New commits afterward mark the review stale. A small push that only touches the lines the review flagged refreshes the review automatically; anything larger won't auto-rerun — mention @claude #update-review in a comment to refresh, or transition through draft and back to ready.
If your change is genuinely trivial (a typo, a one-line fix), opening directly as ready is fine — the pipeline will short-circuit on the review:trivial label.
The repository runs a tiered review pipeline on every PR. AI-assisted contributors should know how it works so they can collaborate with it instead of fighting it.
Transitioning to Ready for review triggers:
- A re-triage to refresh labels (domain, trivial / frontmatter-only short-circuits, prose-flagged signal if applicable).
- The full Claude review (currently
claude-opus-5), composed per touched domain. Findings post to a single pinned comment at the top of the PR — overflow is appended as additional pinned comments tagged<!-- CLAUDE_REVIEW N/M -->.
Mark the PR ready when you're done iterating, not when you start. Each ready-transition produces one full review run; thrashing through draft → ready → draft burns review budget and produces stale pinned comments.
If the PR was AI-drafted, leave the AI authoring trailers in commit messages (Co-Authored-By: Claude ..., Generated with Claude Code, etc.). Stripping them to disguise authorship is bad form and does not change which review runs.
A pinned review goes stale when you push new commits after it ran. One case refreshes itself: when the review had outstanding findings and your push is small (≤80 changed lines) and touches only the flagged lines — the "I fixed what you flagged" push — a deterministic gate auto-fires the scoped #update-review path with no mention needed. Everything else stays stale until you refresh explicitly. Three ways:
@claudemention — hashtag-driven routing. The re-entrant pipeline branches on what you put after@claude:@claude #update-review— refresh the pinned review against the current PR head. Runsclaude-opus-5. Three patterns the update path understands, all of which can appear in the same mention (the pipeline addresses any embedded asks inline before re-rendering the review):- Fix-response ("I addressed your feedback"): re-verifies the previous outstanding findings against the new diff and moves the resolved ones into ✅ Resolved.
- Dispute ("I disagree with the X finding because Y"): re-examines the disputed finding with your evidence; either concedes cleanly or explains why it's keeping the finding.
- Re-verify (no specific request beyond the hashtag): re-checks outstanding findings only.
@claudealone, no hashtag — ad-hoc questions, code fixes, or one-off requests. Tag mode: the action handles it directly with its own animated tracking comment. Doesn't touch the pinned review. Use this when you want help, not a re-review.
- Transition through draft and back to ready — re-triggers the full initial review. Use this when the PR has changed substantially since the last review.
- Wait for the human reviewer — Cam's local
pr-reviewskill reads the pinned comment as source of truth and refreshes it during adjudication if needed.
Rare. Use when the pinned-review state is corrupted (the 1/M comment was manually deleted, the comment sequence is malformed, the review is stuck in a wrong state that #update-review can't reconcile). Clears every existing <!-- CLAUDE_REVIEW N/M --> comment and dispatches a fresh initial review from scratch — same workflow that fires on ready-for-review, just bypassing the trivial / frontmatter-only / draft / bot-author skips. Don't use it for routine refreshes; #update-review is the right tool for those.
A review is finished when every finding it raised has an outcome — not when the 🚨 count hits zero. The pinned comment carries three actionable buckets and they are all yours: 🚨 Outstanding (must be resolved or refuted before merge),
Five outcomes count as done, and no others:
| Outcome | When | What it takes |
|---|---|---|
| Fixed | The finding is right | Push the change. A small push that lands only on flagged lines refreshes the review by itself |
| Refuted | The finding is wrong | Dispute it in a @claude #update-review mention, with evidence. The model concedes cleanly or explains why it's holding |
| Deferred | Real, but out of scope here | File an issue and link it in the PR thread |
| Accepted | Shipping as-is on purpose | Say why, in the PR. An accepted blocker that nobody explained reads as an oversight |
| Not applicable | The finding mis-anchored | Say what it actually points at. Several of these in one review usually means the review went stale — refresh it |
This matters past your own PR. After a PR closes, scrape-review-outcomes.py derives what happened to each finding and aggregates it into the Monday #docs-ops digest, which is how the review's severity rules get tuned. A finding you fixed but never refreshed scrapes as ignored; a finding you disagreed with but never disputed scrapes as ignored too. Both push the pipeline toward flagging more, not less.
Working the list by hand is fine. If you'd rather not, /address-review does it with you: it watches for the review to land, enumerates every item — inline style suggestions included — into one checklist, walks them a finding at a time with a proposed fix for each, batches the fixes into a single push, and writes the #update-review mention. It won't call the PR done while anything is undecided. The same check standalone:
python3 .claude/commands/docs-review/scripts/review-worklist.py --pr <N> \
--state .review-worklist-<N>.json --require-cleanThe <!-- CLAUDE_REVIEW N/M --> comments are managed by the pipeline. Don't delete them — the re-entrant skill expects to find and edit them in place. If you accidentally delete the 1/M summary, the next run posts fresh at the bottom of the timeline; recoverable but ugly.
Don't hide them either. Marking the pinned comment resolved (Hide → Resolved) collapses it but leaves it in place, so a later #update-review edits a comment nobody can see: the job runs green, posts its "🤖 Review updated" progress note, and the refreshed review never appears. The publish path now unhides the comment before patching, but the mutation can be refused by the token's scopes — if a refresh looks like a no-op, check whether the pinned comment is collapsed and unhide it. Use the ✅ Resolved section inside the review to track what you've addressed; that's what it's for.
The pinned comment is also the pipeline's outcome ledger: after a PR closes, a weekly scrape derives what happened to each finding (fixed, conceded, disputed, or merged over) and aggregates it into the Monday #docs-ops digest, which is how the review's severity rules get tuned over time.
Three label-driven short-circuits skip the full Claude review (linters still run):
review:trivial— ≤10 added lines, prose-only body changes, ≤2 docs/blog.mdfiles, no frontmatter changes, no link changes, no code blocks. Typo fixes, wording polish, small same-claim sweeps across siblings, and removal-dominant cleanup (no upper bound on deletions). Marketing/website pages (domain:website) get full review regardless of size.review:frontmatter-only— any number of docs/blog.mdfiles where every change is inside the frontmatter block. Aliases sweeps,draft: falseflips,meta_descrewrites, social copy edits.review:oversized— more than 15K changed lines, or more than 150 changed files. At that scale the bulk is invariably generated output: the review can't finish inside its job timeout and wouldn't add value to generated lines anyway. Triage posts a<!-- TRIAGE_OVERSIZED -->advisory comment suggesting the hand-written source be split into its own PR.@claude #new-reviewforce-overrides the skip.
For both categories, triage runs a focused spelling/grammar pass on the relevant diff slice. If it finds anything, it posts a single advisory comment listing the concerns AND applies review:prose-flagged so reviewers don't miss it. The short-circuit label still applies and the full review still skips. This is a guard against rubber-stamping — a typo "fix" that introduces a typo, or a meta_desc rewrite with a wrong-word substitution, gets flagged before merge.
Classification is deterministic and lives in .claude/commands/docs-review/scripts/triage-classify.py — domain (path-precedence), triviality, and frontmatter-only detection are all path/grep rules. The model is invoked only for the prose check, only when the shell pre-classifies as trivial or frontmatter-only.
The mapping from documentation page to section and table-of-contents (TOC) is stored largely in each page's front matter, leveraging Hugo Menus. Menus for the CLI commands and API reference are specified in ./config.toml.
To share common content across articles, use Hugo Shortcodes. Place a .html file in the [layouts/shortcodes] folder. To include it in a page, use syntax {{< my-shortcode >}}
For example, our custom cleanup shortcode can be included in .md files, to include common text about cleaning up stack resources:
{{< cleanup >}}
HTML layouts can include other layouts inside the layouts/partials directory, e.g.:
{{ partial "head.html" . }}
Front matter is defined as a YAML block at the top of a Markdown document that defines metadata about the page. Pulumi docs pages often include the following front matter variables:
aliases: A list of relative URLs that should point to the content in this page. When moving or renaming a page, you must add analiasentry for the old path of the page relative to thecontent/folder.allow_long_title: Set totruein order disable length validation on thetitleattribute.block_external_search_index: Set totrueto prevent crawlers from indexing the page.h1: If specified, the<h1>at the top of the page will use this value instead of the value in thetitleattribute.menu: Specifies where a page appears in the document navigation tree.meta_desc: Required (unlessredirect_tois set), at least 50 characters, no longer than 160 characters. This displays as the description of the page in web search results.meta_image: Blog posts only. Relative path to an OpenGraph image (1200×628) for social media previews and the blog home page. The image must be a PNG file for compatibility.feature_image: Blog posts only. Relative path to a high-resolution hero image (1884×1256) displayed at the top of the blog post page.meta_title: If specified, the meta title (for OpenGraph) will use this value instead of the value in thetitleattribute.redirect_to: The relative or absolute URL of a permanent redirect.title: Required (unlessredirect_tois set), 60 characters or less. This controls the default value for the<title>tag as well at the top level<h1>in the document.title_tag: If specified, the<title>tag on the rendered call will use this value instead of thetitleattribute.
You can also define arbitrary front-matter variable in the YAML section at the top of a file and refer to that same value in the page content. For instance, the you could add the following front matter foo: "bar", and then reference the variable in markdown with the syntax {{< param foo >}}.
For more information, see Front Matter in the Hugo docs.