| user-invocable | false |
|---|---|
| description | Review criteria for blog posts and customer stories. Fact-check-first; heightened scrutiny by default. |
Applied to blog posts (content/blog/) and customer stories (content/case-studies/). These are usually drafted whole-file (often with AI assistance) rather than edited incrementally, so scrutiny is heightened by default and the whole file is in scope. (Small incremental edits to an already-published post are the exception: the claim pre-step extracts from the changed lines only -- see docs-review:references:fact-check §Scope.) On this whole-file path, a pre-computed readthrough coherence pass (docs-review:references:readthrough) reads the post end to end and surfaces anchored structural defects as 🚩 flagged findings in your buckets — triage each with the standard two-question test.
Fact-check-first treatment. Fact-check is the headline finding bucket. Get it right before commenting on AI-writing patterns or structure.
- Whole-file read is mandatory. Diff-only is not enough -- AI-drafted blogs hallucinate in the surrounding prose, not just the changed lines.
- Pre-existing extraction is always on for blog files (see below).
The following reference files apply alongside the blog-specific priorities below. Consult each as content in the diff triggers a relevant rule:
docs-review:references:shared-criteria— every file (links, frontmatter, shortcodes)docs-review:references:code-examples— wherever code appearsdocs-review:references:prose-patterns— prose-bearing contentdocs-review:references:image-review— wherever images appear
Investigate as content triggers each priority below.
Invoke docs-review:references:fact-check (scrutiny=heightened) before any style pass. The reference owns claim extraction; in blog copy, pay particular attention to performance multipliers, competitor claims, and adoption / market-position statistics — common in this domain and high-blast-radius when wrong.
Apply docs-review:references:prose-patterns and docs-review:references:spelling-grammar.
Blog-specific patterns (apply alongside the shared references):
- TL;DR / summary paragraphs that restate the post. The reader just finished reading; they don't need a recap. Quote the recap; propose removal.
- Self-criticism of prior Pulumi decisions. "We used to handle this badly," "the old way was wrong," "before we got this right." Acceptable in case-studies discussing a customer's prior tooling; not acceptable when describing prior Pulumi product behavior. Quote the construction; reframe as forward-looking: "v3.0 introduced X" not "before v3.0, we got it wrong."
- Weak conclusions. A closing paragraph that doesn't name a specific next step. "Check out Pulumi to learn more" without a specific link or command. Quote the conclusion; propose a concrete CTA: "Try it:
pulumi upagainst the example at<link>" or "See the X reference at /docs/foo/." - Listicle bloat. Posts structured as
## item N:patterns or numbered top-N lists. Cap at 12 items; cap total post length at ≈3,000 words for listicles. If a list goes longer, suggest which items to cut or merge.
Compute and render the editorial-balance pass on any post matching one of the trigger patterns below. The output renders as ### 📊 Editorial balance per docs-review:references:output-format; threshold flags below also surface as
Three-tier computation: Tier 1 (listicle / FAQ trigger detection, section-depth statistics, outlier flag) is deterministic and runs in the workflow's editorial-balance-detect.py pre-step — its output is .editorial-balance.json. Tier 2 (comparison-trigger heuristic, entity counts, recommendation steering, FAQ-answer voting) remains model-computed. Tier 3 (don't-flag exceptions) stays model-judged. The validator's editorial-balance-counts-faithful rule cross-checks rendered Tier 1 fields against the JSON.
Trigger patterns (any one fires the pass):
- Comparison (Tier 2, model-computed): ≥3 H2 sections under the same parent reading as parallel entities (vendors, products, approaches), e.g.,
## Pulumi,## Terraform,## OpenTofu. - Listicle (Tier 1, in
.editorial-balance.json): H2s of the form## item N:or## N. ...at the same nesting level. - FAQ (Tier 1, in
.editorial-balance.json): an H2 named "Frequently asked questions" (case-insensitive), or any heading nested under it.
When none fire, render the explicit-empty form per output-format.md (don't skip — empty is the signal that the check ran). When .editorial-balance.json reports trigger=null, the empty form is mandatory; the validator trips on rich-form rendering against a null trigger.
Computation rules:
- Section depth (Tier 1, sourced from JSON when present): For each H2 (or each numbered listicle item), count body lines (paragraphs, code blocks, sub-headings) excluding blanks and frontmatter. Report mean, median, std. Outlier: any section ≥3× the median. The pre-step computes these from the post-PR file body and writes them to
.editorial-balance.json; render the same numbers in the section. - Entity mentions (Tier 2, model-computed): Identify the entity set from H2 names. For each entity (including product-line names — e.g., "Pulumi" subsumes "Pulumi Cloud," "Pulumi ESC"), count whole-word case-insensitive occurrences across the body.
- Recommendation steering (Tier 2, model-computed): Count
(use|choose|pick|recommend|prefer|go with|stick with) <entity>,<entity> is best,<entity> wins, and the inverse(avoid|skip|don't use) <competitor>. Group by entity. For FAQs, count each answer as one steering vote toward whichever entity it pushes.
Threshold flags (each surfaces as a ⚠️ Low-confidence bullet quoting the offending section/heading):
- Any one section is ≥3× the median section length (Tier 1; the deterministic detector flags these in
.editorial-balance.jsonthreshold_flags). - Any one entity captures ≥5× the recommendation real estate of competitors in a comparison post (Tier 2; skip if total recommendation count <5).
- A single entity captures ≥60% of FAQ-answer steering in a multi-vendor FAQ (Tier 2; skip if <5 answers).
Don't flag (Tier 3, model-judged) when:
- The post is a single-subject feature announcement and the comparison trigger fired only on parenthetical competitor mentions ("Unlike Foo and Bar, ...").
- The comparison-set is intentionally asymmetric and named as such ("Why we chose X over Y; this post focuses on X's tradeoffs").
Data renders regardless; only the threshold flags suppress.
Every blog post carries a required, singular category value — the kind of post, a closed set in data/blog_categories.yaml (the single source of truth for ids, names, and use_when hints). lint-markdown.js already enforces the mechanical rules (present; a single scalar, not a list; a valid id) and make lint fails on violations — do not re-flag those here. This lane owns the semantic check that a deterministic linter can't do: does the post's content actually match the kind it claims?
general is the default catch-all — the correct home for posts that don't fit a specific kind (SEO comparisons, "what is X" explainers, "top N tools" listicles, trend roundups, marketing/solution pages). The job of this lane is to make sure a post landed in the right place relative to general.
Read the post whole-file (already mandatory) and load the kind definitions (the scope field — not the reader-facing description) + use_when hints from data/blog_categories.yaml. Then make these judgments, each surfacing as a ⚠️ Low-confidence finding (never a publishing blocker — categorization is editorial and the author may have context you don't):
-
Over-promoted into a specific kind — should be
general. This is the priority case. A post categorized as a specific kind (best-practices,product,tutorials, …) that is really SEO/marketing content with no genuine substance of that kind: a comparison ("X vs Y") filed asproduct, a "top/best N tools" listicle or "what is / why choose / when to use" explainer filed asbest-practices, a trend/prediction roundup filed asperspectives, a thin solution/funnel page filed asproduct. Quote the title + the deciding structural evidence (comparison framing, listicle headings, no runnable steps, no original thesis) and proposecategory: general. -
Wrong specific kind. The declared kind doesn't match the content, but another specific kind does: a step-by-step walkthrough filed
best-practices(it'stutorials); a feature-launch announcement filedtutorials(it'sproduct); a named-customer story filedcommunity(it'scustomers); an internals deep-dive filedbest-practices(it'sengineering). Quote the evidence; propose the better id. -
Under-categorized —
generalthat clearly fits a specific kind. A post left ingeneralthat plainly matches a specific kind (a real release announcement, a genuine hands-on tutorial, a named-customer story, an original-argument essay). Quote the evidence; propose the specific id.
The subtle boundary — perspectives vs general. Both can look essay-like. perspectives is an author making an original argument or analysis in their own voice (a thesis, a point of view); general is for content that exists mainly to rank for a search query or sell. Use intent, not title shape: The Past, Present, and Future of Cloud Engineering (original argument → perspectives) vs Why Choose Pulumi Over Terraform? (search-funnel comparison → general).
Keep the bar high. Flag only clear mismatches, not marginal judgment calls between two defensible kinds — this is a nag, and noise erodes it. When two kinds genuinely both fit, say which is dominant and stop; don't flag. One finding per post maximum (the category is a single field). Quote-and-rewrite mandate applies: name the current value, quote the deciding evidence, and propose the specific id (or general). The Priority 2.5 editorial-balance triggers (comparison / listicle / FAQ) are strong corroborating signals for case 1.
Apply docs-review:references:code-examples.
Vale catches Pulumi product-name capitalization, the Pulumi Policies singular-verb rule, and "public preview" vs "public beta" (surfaced under docs-review:references:output-format §Style suggestions). The reviewer's job here is the things Vale can't:
- Feature names. Capitalization and punctuation must match how the product refers to itself in docs. If a blog introduces a feature, the feature name should match the canonical doc page's title.
- "Generally available," not "generally released." Release terminology beyond what Vale's substitution list covers.
- Canonical links to docs. Every feature announcement should link to the relevant
/docs/page. Missing doc links are a pre-existing-issue finding (the blog post is fine on its own; it's the site SEO that suffers). - "New" vs "now supports." A feature that landed more than ~30 days ago should use "now supports" or "recently added," not "new." If the frontmatter
dateis old relative to the claim's subject, flag. - Title quality. Title should describe the post's subject specifically and contain the topical hook a search/AI user would type. Flag:
- Clickbait constructions ("You won't believe...", "10 things every X needs"), question-headlines without a clear payoff.
- Title/body mismatch. Quote the title and the post's first paragraph; flag when the body's actual subject is materially different from what the title sells (e.g., title is "Improving Pulumi Performance," body is specifically about Bun-runtime startup time).
- Generic titles missing the topical hook. "Improving Performance" or "A New Approach to X" without naming the product, feature, or specific outcome. Quote the title; propose a more specific rewrite that includes the primary subject.
When a blog post announces a new feature, provider, or significant capability:
- Check that
/content/docs/covers it. Search for the feature name acrosscontent/docs/. If the only mention of the feature is the blog post itself, that's a finding. - Note specific gaps. Don't just say "docs are missing" — name the page that should exist (e.g., "no
content/docs/esc/integrations/<feature>/page found"). - Suggest a doc type. Reference / tutorial / concept guide / how-to — pick the one that matches the feature's nature.
This is a project-completeness flag, not a blog quality issue.
Concrete rules from seo-analyze:references:aeo-checklist applied at review time. Quote-and-rewrite mandate: every finding names a specific construction and proposes a fix.
- Quotable opening paragraph. The first 1–2 sentences should answer "what is this post about" as a standalone definition, with no fluff intro. Quote the opening; flag empty transitions ("In this post, we'll explore...", "Let's dive in", "In recent years...") and propose a direct first-sentence rewrite that names the subject.
- Answer-first H2 headings. For concept-heavy posts, prefer question-style or how-style headings ("How does Pulumi ESC handle secrets?") over label-style ("ESC overview"). Label headings rank lower for AI answer extraction. Quote the heading; propose an answer-first rewrite. Don't flag label headings on action posts ("Get started," "Install Pulumi") — those are correct.
- Specific data over vague superlatives. "Pulumi is much faster" / "many users adopted X" / "significantly improved" without numbers. Quote the claim; propose a specific number, percentage, or comparison. Where the post genuinely lacks data, flag for fact-check rather than rewrite.
- Down-funnel specificity. A feature post that introduces the feature but never shows a concrete integration, command, or code example is too generic to rank or be cited. Quote the most generic section; propose adding a specific use case (named integration, CI flow, edge case).
- Numbered, executable steps for "how-to" content. "Get started" / "Set up X" sections that read as prose instead of numbered steps with copy-pasteable commands. Quote the section; propose a numbered list with explicit commands.
- Dated context where it matters. Posts that describe behavior tied to a specific Pulumi version or external state should name it ("As of v3.150…", "On 2026-04-29…"), not assume the reader knows. Flag undated state claims.
- All links resolve. Inherited from
docs-review:references:shared-criteria. - Link text is descriptive. Inherited.
- First mention is hyperlinked. Every tool, technology, or product's first mention in the post should be a link (to docs, to the project homepage, to a GitHub repo). Flag only first-mention misses; subsequent mentions don't need the link.
- Missing cross-link to canonical Pulumi docs. When the post mentions a Pulumi concept with a canonical doc page (stacks, providers, components, ESC environments, projects, programs, policy packs) and no occurrence of the term is hyperlinked, flag it once per concept. Quote the most prominent unlinked occurrence; propose the link target (e.g.,
[stacks](/docs/iac/concepts/stacks/)). Complements the rule above — that one covers external tools and projects; this one covers internal Pulumi concept docs. {{< github-card >}}references. Formatowner/repo; verify the repo exists (gh api repos/<owner>/<repo>). A broken card renders as an ugly empty block.
Blog files are usually new in their entirety, so the diff/pre-existing distinction blurs. For incremental edits to existing posts, separate diff-introduced from pre-existing per the standard rules in docs-review:references:output-format.
Scope of pre-existing findings for blog: everything from docs-review:references:docs, plus unsourced numerical claims, temporally-rotted feature claims ("a new feature in v3.X" where v3.X is years old), broken {{< github-card >}} references, missing author avatars, and — only when a post sets a custom meta_image override — one that uses outdated Pulumi logos (the brand refresh moved on; old logos hurt social sharing). Blog posts otherwise leave meta_image blank and get an on-brand card at build time from the title + feature_image, so an absent meta_image is correct, never a finding.
- Colloquialisms as inclusive-language violations. "Overkill," "kill the process," "kick off," "blow away" are fine in technical context.
- Drafting social copy, CTAs, or button text. Marketing owns voice; do not propose replacement copy.
- Meta image colors, composition, or layout. Do not critique design choices. (See §Publishing blockers for the custom-override retired-logo and animated-GIF cases.)
- Vague editorial feedback without quote-and-rewrite. "Consider rewording for engagement" / "this could be clearer" / "you should reorganize this section" without a quoted construction and a specific proposed rewrite is editorial vagueness, not a review finding. Concrete prose, structural, and SEO/AEO suggestions (apply
docs-review:references:prose-patterns; split a mixed-concept H2; rewrite a label-style heading as answer-first) ARE in scope -- but every finding must quote the offending text and propose the fix. - Heading case. markdownlint owns case-consistency; Vale owns product-name miscapitalization (e.g., "Pulumi esc"). Don't flag either here.
- Anything Vale catches. Product-name capitalization, Policies-singular, public-preview/public-beta, click→select, banned words, difficulty qualifiers — all surface via
.vale-findings.jsonperdocs-review:references:output-format§Style suggestions. Don't double-flag.
Each item below renders as a single 🚨 Outstanding finding when violated. Quote-and-rewrite mandate: name the field or file, propose the specific fix.
- A custom
meta_imageoverride uses retired Pulumi logos. Only applies when a post sets its ownmeta_image— most posts leave it blank and get the on-brand build-time card, so there is nothing to inspect. When an override is present, inspect it (its filename / path or rendered image) for retired brand variants; quote the path and propose the current-brand replacement, or suggest dropping the override to fall back to the build-time card. (A renamedmeta-legacy.pngarchive image is not ameta_imageand is never flagged.) meta_imageanimated-GIF / format constraints — seedocs-review:references:image-review.<!--more-->break missing or buried. The break must be present and land after the first 1–3 paragraphs, not buried mid-post. Without it, the entire post body renders on the blog index. Quote the surrounding paragraphs; propose the correct placement. Skip ondraft: trueor archival posts.feature_imagemissing. Active blog posts (not draft, not archival) must setfeature_imageto a hero image in the post's directory. New posts scaffold with the field blank — that's the starting state, not the shipping state. Two exemptions, both deliberate:category: general(the catch-all bucket — SEO comparisons, "what is X" explainers — which is lower-touch by design) anddraft: true(a work in progress; the image lands before undrafting). Flag the empty field; propose running/blog-feature-image, labeling the PRneeds-designfor a designer-made image, or — if the post really is a catch-all — moving it tocategory: general. Posts predating the blog redesign are grandfathered; only flag posts new or changed in this PR.social:block missing or empty. Active blog posts (not draft, not archival) must have asocial:frontmatter block with at least one oftwitter,linkedin, orblueskypopulated; without it the post won't be promoted. Flag the missing/empty block; do not draft the copy (marketing owns voice).- Author profile avatar missing.
data/team/team/{author}.yamlmust reference an avatar file. Quote the missing field or the path of the file that should exist.
Other publishing-readiness items (title ≤60 chars, meta_desc length, meta_image .png extension, code language specifiers, image alt text and borders, link resolution) are handled by lint-markdown.js or by other references (docs-review:references:shared-criteria, docs-review:references:code-examples, docs-review:references:image-review). Don't re-flag them here.