Generated from cat-harness/skills/sdlc/sdlc-core/staging-review.md — do not edit here. Typed contract: schema reference.

✎ Edit this page’s source

Staging review — before/after comparison

Skill id: staging-review · Package: sdlc-core

Provide before/after URL pairs whenever rendered content has changed in a feature branch. This is part of every review or feedback session involving visual content.

URLs tell the reviewer where to look. The captured, measured pair that goes with them is before-after-preview.

This is the one home of the rule. Every skill or process that asks a person for feedback, review, sign-off or acceptance on something rendered (CRDM, SDLC code review, content authoring, translation, wireframes, a DAK or a guideline) points here rather than restating it. Owner, 2026-10-14: “update your feedback skills as part of crdm, SDLC, other content authoring processes, if there is a staging URL provided, ideally with a deep link to the relevant features being edited or shown”, and the request it repeats, beans u1os, 28k2 and g4dv.

  1. When a staging preview exists, the request carries it. A request for feedback with a PR link and no staging link asks the person to read source in place of the thing they are judging.
  2. One deep link per changed thing: the page, and the anchor on it where the change sits (…/STAGING/<slug>/<path>#<anchor>), each with the same page on main as before (or “new page”). The site root is the right link only for a change to the landing page itself.
  3. The pages come from the change, not from judgement. Start from the preview’s changes page, …/STAGING/<slug>/changes/, when the build wrote one: the changed sources, the rendered files in their dependency cone, and the files that differ from the published root but were NOT in the cone (each of those is a page to list, and a cone defect to report). Otherwise read rendered-impact.json beside the preview when the staging build wrote one (skill rendered-impact), or run the renderer’s impact Tool for the branch. Never build a URL from a source path: look each one up in the publish ref (§”You can open it yourself”).
  4. The third state is said, not hidden. No preview yet: say so, and when it will exist. A changed page the publish ref does not hold: a finding, listed with the others. No page list could be computed: say that, never “nothing changed”.
  5. Where it goes is a judgement, and more than one place may be right. Human feedback is gathered through several Tools: the agent conversation (the turn that asks the person), a GitHub issue, a pull request (its body, or the comment the staging workflow posts). Owner, 2026-10-14: “it is a Judgement as to the context which one to use. more than one can be used.” Ask where the person will look. Work driven through, or primarily referencing, a GitHub issue may warrant the request on that issue as well as in the turn; a change reviewed on its PR carries it there. Whichever places are chosen, each carries the same deep links. This rule fixes what a request contains, not a list of places it must be posted.

A skill whose output asks for feedback declares it: its output schema carries a stagingLinks field of type StagingComparison (schemas/staging.ts), and a skill that consumes feedback takes the same field as input, so the links travel with the request rather than being rebuilt.

Signature — one optional string in, a reviewable list out

Stated formally because this skill is invoked by other processes and by a person typing /staging-review, and a contract that lives in prose is one each caller re-derives.

Input

typestring
cardinality0,1 — optional, and at most one
meaningwhat the reader wants to look at, in their own words
absentthe whole preview: every page this branch changed

Keep the wording verbatim wherever the output echoes it — the same rule goal-review applies to a goal, and for the same reason: a request the agent paraphrased is a different request, and the reader cannot tell which one the list was built for.

It narrows; it never adds. “The navbar” gets the pages carrying it, not a page that mentions it. An ask matching nothing that changed is said, not answered with the whole preview — a list that silently ignores its input is worse than an empty one, because the reader believes it was answered.

Output

Markdown, in this order. Every part is required; an absent part is stated, not dropped.

  1. What changed, in prose — a short narrative, before any link: what a reader of the site will now see or do differently, in plain sentences, and why (the request it answers, in the requester’s words where there are any). One paragraph per PR or per coherent change. A table of URLs tells a reviewer where to look; only the narrative tells them what they are looking for, and a reviewer who has not read the code cannot reconstruct it from a filename (owner, 2026-10-01: “provide/show before/after, provide narrative description of change”).
  2. Where to start — one line, one URL: the one that best answers the input, or the page with the most change behind it when there was none. A reader opens one thing first whether or not you choose it for them.
  3. The comparison table below — before (main), after (staging), what to review. Links are deep — to the page, and to the anchor on it where the change sits (…/#harness-cat-harness), never the site root with “scroll down”.
  4. The pictures — a before/after pair for each visual change, SHOWN to the reviewer (sent as images where the surface allows it), not only linked. A link asks the reviewer to do the comparison; a pair does it for them. How to make a fair pair is before-after-preview. If a pair could not be made, say which and why — that is part 5, not a silent gap.
  5. What could not be checked, per the three states below.

The third column is the one with value

What changed must be phrased as something to look at, never as a restatement of the filename. Build it by mapping changed files to published surfaces:

what changedwhich pagewhat to say
a stylesheet or client scriptevery page that loads itthe affordance that changed, and the gesture to make
a template or includethe pages that include itwhere on the page to look
generated contentthe page it generateswhat it was generated from — a stale generator and a correct one look identical on the page
a schema, a test, a gatenonesay so, and point at the diff
folio content (block manifests and prose)the page each block renders onthe ChangeSet’s aspect: reworded, moved, renamed, added, removed. See below.

Where a change is only visible after an interaction — a control behind a disclosure, a mode a reader turns on — say WHICH interaction. A reviewer who cannot find the thing reports it as missing. That failure is on the record: a contrast defect survived two days behind a tile nobody clicked (bean rptk), because the gate that swept the page never opened the view.

You can open it yourself — the publish ref is a git branch

Everything above composes URLs for a reader. The agent writing them usually cannot open them, and that gap has a measured cost.

Probed 2026-09-22 from inside a session container, all three routes:

routeresult
curl https://<owner>.github.io/…403 — CONNECT tunnel failed (policy denial)
WebFetchEGRESS_BLOCKED
Playwright / Chromiumnet::ERR_TUNNEL_CONNECTION_FAILED

A browser is not a way around it — Chromium is proxy-configured too, so “use Playwright” fails identically. That is worth stating because it is the first thing anybody tries.

WHAT IT COST: twelve navbar tiles shipped pointing at the ORIGIN rather than the site, because their href carried no baseurl (#801). Over a hundred green gates were fine with it, and the owner found it by clicking one — which is exactly rptk’s shape one layer out. Nothing in the repository could look at what deployed.

But the site is published to the gh-pages BRANCH, and git works. So the deployed bytes are readable with git show, and a Playwright route handler can serve them at their real path:

const blob = (p: string) => {
  try { return execFileSync("git", ["show", `origin/gh-pages:${ROOT}/${p}`], { maxBuffer: 1 << 28 }); }
  catch { return undefined; }                 // absent is a FINDING, not a 200
};
let missing = 0;                              // REPORT THIS. See below.
await page.route(`${ORIGIN}/**`, (route) => {
  const u = new URL(route.request().url());
  let p = u.pathname.startsWith(BASE) ? u.pathname.slice(BASE.length) : u.pathname;
  p = p.replace(/^\/+/, "");
  if (p === "" || p.endsWith("/")) p += "index.html";
  const b = blob(p) ?? blob(`${p}/index.html`);
  if (!b) { missing++; return route.fulfill({ status: 404, body: "not in the publish ref" }); }
  return route.fulfill({ contentType: typeFor(p), body: b });
});

ROOT is "" for the canonical deploy and STAGING/<slug> for a preview, so the same handler gives you the before/after pair this skill is about — and a count, not an impression. Fetch the ref first (git fetch --depth=1 --filter=blob:none origin gh-pages); blobs are then fetched on demand.

Serve it under the real BASE. A page served at the origin root cannot reveal a missing baseurl, because “composed against the base” and “not composed at all” are the same string there. That is precisely how #801’s e2e stayed green over the defect.

And BASE is NOT the site’s base when you are reading a preview. A preview is published at <origin>/folio-assistant/STAGING/<slug>/, so that whole prefix is what its pages compose hrefs against — BASE and ROOT carry the same staging segment, one in the URL and one in the ref:

readingROOT (in the ref)BASE (in the URL)
the canonical deploy""/folio-assistant
a STAGING previewSTAGING/<slug>/folio-assistant/STAGING/<slug>

Getting that wrong doubles the segment — STAGING/<slug>/STAGING/<slug>/... — and every asset 404s.

Report the absent count, and read it before anything else

A run that could not fetch the page’s assets has established nothing, and its other numbers are not merely unreliable, they are actively misleading. This page’s own technique produced, on a deploy that was in fact correct:

STAGED SITE — 0 distinct tiles, 14 asset(s) absent from the ref
OFF-SITE hrefs (#801): 0
badged: 0

Read at face value that says the staged site has no tiles — which is the shape of the very defect this section exists to catch. It was a broken rig: the BASE above. The corrected run on the same commit gave 23 tiles, 0 absent, 12 badged.

So the rule, and it outranks every other number the run prints:

missing > 0 is COULD NOT DETERMINE. Not clean, and not a finding. Discard the run’s other counts rather than reading them, fix the rig, and re-run.

dh4f is this repository’s name for scanning nothing and reporting it clean. This is its mirror — scanning nothing and reporting it broken — and it is the more expensive of the two, because an agent acting on it goes looking for a defect that is not there, or “fixes” one.

What this does NOT verify, and it matters

It serves the bytes in the publish ref. It is not the live host, so it cannot see:

  • redirects, headers, or anything else the host decides at request time
  • Pages’ own 404 handling, or its directory-index rules where they differ from the handler above
  • whether the deploy has actually published — a ref can hold bytes that Pages has not served yet (§”Say how long”)

So a green result here means “the published bytes are right”, never “the site works”. Report it in those words. A stronger claim than the method supports is the thing this skill’s third-state table exists to stop.

When it stops applying

The technique rests on one assumption: publishing goes to a branch. If this repository ever moves to the Pages artifact API, git fetch origin gh-pages fails and there is nothing to read.

That failure must be reported as could not determine, never as a clean run — an empty ref and a correct deploy are opposite facts, and dh4f is this repository’s name for the defect of scanning nothing and calling it clean.

The third state, here as everywhere

“Could not determine” is never rendered as clean — and a change with no rendered surface is not a failure, it is the useful answer.

casereport it as
not deployed yetnot deployed, with the timing from §”Say how long” — never a link to where it will be
a changed page absent from the publish refa finding — the build dropped it, or it is not a page
no rendered surfacereviewed in the diff, naming the files

The middle row is the most useful thing this skill can report, and composing its URL anyway would hide exactly that — §”Before you report a staging URL as broken”.

The commit in the bot’s comment is NOT your branch head

Measured 2026-09-21, by getting it wrong in this skill’s own first use. The comment named 3894c41; the branch head was 037812c7; I reported the preview as “one commit behind”. It was current. On a pull_request trigger the stage job builds refs/pull/<n>/merge — your head merged into the base — so the commit it names is an object that never equals your head and is not even in your clone. Comparing the two reports a stale preview on every PR, forever.

Resolve it instead. The merge commit’s second parent is the head it was built from:

git fetch origin "refs/pull/<n>/merge:refs/remotes/origin/pr-<n>-merge" -q
git log --format="%h %p" -1 origin/pr-<n>-merge   # <merge> <base> <YOUR HEAD>

Stale means that second parent is not your head — nothing else does. This is the same fact bun run cat gates exists around: CI tests the merge, and so does the preview, so a branch behind its base is previewing a tree nobody will have.

A stub where an instance should be is the cone, not a broken build

A composed instance the branch cannot reach is replaced by a one-page stub pointing at the published copy. Which instances a preview carries is decided by the staging cone (feature-staging §7), and the build log prints each decision with its reason. Before reporting a missing IG as a defect, read that line. If the cone left out something your change does affect, the fix is a missing writer or derivedFrom on the directory’s declaration, not a rule to always carry it.

Which pages to list: the Change Set’s rendered list

When the change is a Change Set, do not choose the before/after pages by judgement: take them from its rendered list (skill rendered-impact), every content page on it, and say which inputs the renderer could not place. A page the list does not name and you add anyway is fine; a page on the list that you leave out is a gap the reviewer cannot see.

When to provide before/after URLs

Provide before/after URLs in every interaction where:

  1. Content has changed on a feature branch (authoring, editing, translation)
  2. CRDM feature development — once requirements are agreed and a staging preview exists
  3. Review/feedback sessions — when the user asks to review changes
  4. Translation review — comparing translated vs source pages

Before/after URL schema

Every feedback response involving rendered content must include a stagingComparison object:

interface StagingComparison {
  /** Feature branch name (e.g. "claude/206-staging-preview"). */
  branch: string;
  /** URL to the branch on GitHub. */
  branchUrl: string;
  /** PR number if one exists. */
  pr?: number;
  /** Pairs of before (main) and after (staging) URLs for changed pages. */
  pages: Array<{
    /** Page title or path. */
    title: string;
    /** URL on the main (production) site. */
    before: string;
    /** URL on the staging preview site. */
    after: string;
    /** What changed on this page. */
    summary?: string;
  }>;
}

Example output

When reporting changes to the user:

### 📋 Staging comparison

| Page | Before (main) | After (staging) | What changed |
|---|---|---|---|
| Landing page | [main](https://litlfred.github.io/folio-assistant/#languages) | [staging](https://litlfred.github.io/folio-assistant/STAGING/claude-206-staging-preview/#languages) | Added French translation badge (the landing page itself changed, so its root is the page; the anchor is where the badge sits) |
| French landing | — | [staging](https://litlfred.github.io/folio-assistant/STAGING/claude-206-staging-preview/fr/index.html) | New page |
| Agent onboarding | [main](https://litlfred.github.io/folio-assistant/docs/cat-harness/guides/agent-onboarding.html) | [staging](https://litlfred.github.io/folio-assistant/STAGING/claude-206-staging-preview/docs/cat-harness/guides/agent-onboarding.html) | Language switcher added |

URL construction

Main (before)

https://<owner>.github.io/<repo>/<path>

Read from the instance’s config (its index.config.json entry) → readme.pagesBaseUrl, or construct from the repo’s GitHub Pages URL.

Staging (after)

https://<owner>.github.io/<repo>/STAGING/<branch-slug>/<path>

The <branch-slug> is the branch name with non-alphanumeric characters replaced by hyphens (the same transform as feature-staging.yml).

Deriving the slug

function branchToSlug(branch: string): string {
  return branch
    .replace(/[^a-zA-Z0-9._-]/g, "-")
    .replace(/-{2,}/g, "-")
    .replace(/^-|-$/g, "");
}

Integration with CRDM

In the CRDM requirements workflow (Phase 5: implementation), once a staging preview is deployed:

  1. Post the staging URL on the GitHub issue as a comment. For a folio, post the review page too, STAGING/<slug>/review/, and name it as the page a reviewer opens first. The folio-staging workflow’s PR comment carries both links. The page lists every changed block, not only every changed page. Posting the site root alone sends a reviewer hunting through a 300-page document for a one-word edit.
  2. Include before/after table for every changed page
  3. Link from the PR body to the staging preview
  4. Remind reviewers that staging uses the magenta “FEATURE BRANCH” banner

The staging preview is part of the acceptance criteria: stakeholders must review the rendered output, not just the code diff.

Integration with content authoring

When an author has made content changes on a feature branch:

  1. Compute the ChangeSet, not a file list, when the branch touches a folio’s folio graph: the folio-changeset Tool, with --folio <folio dir> --base origin/main --head <branch>. Its command line is the Tool node’s invoke, declared by the content layer that defines the folio graph kind; this skill names the Tool, not that layer’s path. It says, per block, whether it was added, removed, reworded (prose), edited (manifest), moved or renamed. A file list cannot: a prose-only edit changes no .ts, and a move changes two files that look unrelated. Rank “where to start” by it. For a large document, the section with the most changed blocks is where a reviewer’s time goes first.
  2. Construct before/after URLs for each changed docs page, and for each page carrying a changed block
  3. Present the change to the author in the Output order above: the narrative first, then the deep-linked comparison table, then the before/after pictures themselves
  4. Offer to run the staging workflow if not already running

Finding your way on the review page (bean eb4l)

A 300-page review needs to know where it is, and to move in one key or one click. The review page has a navigation pane, beside the list on a wide screen and above it on a narrow one:

  • Outline. Each document’s chapters and sections, in manifest order, from the outline.json the folio’s site build writes.
    • A section with something to review is a button that moves focus to it, with word badges (“2 changed, 1 comment, QA failing”).
    • Any other section links to its published page.
    • By the owner’s ruling it is on the review page only. The “no toc” ruling for normal pages stands.
  • Minimap. One cell per block, in order. It is one tab stop: Up and Down move, Home and End jump to either end, and Enter opens the block. Each cell is labelled in words and marked with glyphs (Δ changed, + added, ● open comments, ! QA failing), so colour is never the only signal.
  • Where am I. Every move announces “document › chapter › section › block” in the page’s one live status line.

Keys, each with a visible button twin:

keybuttongoes to
jNextthe next item
kPreviousthe previous item
nNext with commentsthe next block with open comments
pPrevious with commentsthe previous block with open comments
uNext unreviewedthe next changed block with no reviewer verdict on its CURRENT version (bean px0t)

“Unreviewed” means no verdict on this version, not “no comments”. A block whose only verdict is on an earlier version is unreviewed, and says so in words (“1 verdict on an earlier version, not counted”). A build whose review-comments.json carries no verdicts says “No verdict data on this build” rather than treating every block as unread. Typing into a selector is never navigation.

Tests. the tools layer’s test/review-nav.e2e.ts drives all of this with the keyboard alone. It uses no mouse, no click and no hover.

The review page, and choosing how to see a change

A folio’s preview carries review/index.html: every changed block, grouped by section, with its reviewer comments (the review-comments skill). Each changed block has a “Show this change as” selector (bean d903), and one at the top applies to every block:

renderershowsthe default forneeds
Word diff of the sourcethe Markdown with removed words struck and added words marked: exactly what the author typedany kind not listed belowthe block’s prose
Inline, as renderedthe block as a reader sees it now, with the change marked in placeprose, remark, definition, examplethe block’s prose
Pictures, before and aftera picture of the block on each side, the changed pixels marked, and the share changed in words (visual-diff, bean 0rxe)table, figure, diagram, equation, simulatorvisual-diff.json from the folio-block-screenshots Tool
Side by sidemain and the preview next to each other, each scrolled to the blockthe same kinds, as the second choice when a build published no picturesa page on either side
  • Where the data comes from. The ChangeSet step’s --text-out writes changeset-text.json: the source and rendered prose of each listed block, on each side that has it. The pictures step writes visual-diff.json and visual/. With neither file, only side by side is available, and the page says so.
  • Two renderers may default for one kind. Order is preference: the page opens a block on the first renderer that lists its kind AND can run.
  • A renderer that cannot run on a block is listed, disabled, with the reason. For example, a manifest-only change has no prose to diff. If the page-level choice cannot run on a block, that block falls back to one that can.
  • The page-level choice is remembered for this viewer in localStorage. A private window just gets the defaults.
  • The renderers and the registry are cat-harness-tools/scripts/review-renderers.ts, cat-harness-tools/scripts/word-diff.ts (no diff library; about forty lines, tested) and cat-harness/schemas/diff-renderers.ts. To add one, declare it in the registry with what it needs and which kinds it defaults for, add its function to review-renderers.ts, and add a case to the page’s renderInto.

Staging retention

A MERGED pull request’s preview goes away. Everything else is retained by default. Owner, 2026-09-20: “change policy, if merged to main, then staging goes away” — replacing a blanket retain-on-close.

The distinction is about where the content lives, not about how confident anyone is:

the PR wasthe preview isso
mergedthe same thing the MAIN SITE now showsredundant on the instant, and removed automatically — no label
closed, unmergedthe ONLY rendering of that workthe last copy, and retained unless a person says otherwise

The store DRAINS, and the size threshold depends on it. Under the old retain-on-everything policy the total only ever grew, so any warning point was breached once and stayed breached — a permanent verdict rather than a signal. Now what remains is bounded by concurrent reviews, not by cumulative history, which is why the warning point could move from 100 MB to 500 MB on 2026-09-20 and start meaning something again: at the measured ~38 MB per preview it is about thirteen concurrent reviews, and half the documented 1 GB Pages ceiling.

The floor still governs and pruning is still not the action: a live branch’s preview must not be removed, so N concurrent reviews floor the total at N × ~38 MB. What changed is that the floor now falls on its own as work merges, instead of being carried forever.

This is not a relaxation of deletion-requires-confirmation — it is that rule applied more precisely. The merge is the confirmation: a person decided this content belongs on main, which says more about the preview than a label does. What the label still guards is the case where nobody decided anything.

And it does not reverse plj1. That failure deleted every open PR’s preview — work nobody had accepted, mid-review. Nothing here touches an open PR, and the one-directory-at-a-time shape that bean forced on the job is unchanged.

Which mechanism depends on whether the PR is still open, and that is not a detail. Three cases, and the label cannot reach a closed PR:

  1. Merged — nothing to do. cleanup reads github.event.pull_request.merged off the close event and removes without a label, recording the reason as merged.
  2. While the PR is open, and it will not be merged — add the staging:cleanup label. cleanup in feature-staging.yml fires on pull_request_target: closed and reads the labels off that event, so the label has to be there before the PR closes.
  3. Once the PR is closed unmerged and unlabelled — run feature-staging.yml from the Actions tab with cleanup_slug: <slug> and cleanup_confirm: <slug>. The confirmation repeats the slug so that it names the artefact it confirms, and the job re-checks that nothing is still using the preview before it removes anything.

Labelling a closed PR does nothing, and re-running its old workflow run does nothing either — a re-run replays the stored event payload, which still carries no label. That was the state of things until bean w2g5: the health sweep’s staging-preview-orphans finding can only ever name a preview whose PR is already closed, so the remedy it documented was unreachable for every orphan it could report. If you find yourself reaching for a hand-pushed gh-pages commit instead, stop: that is the unilateral removal deletion-requires-confirmation exists to stop, and option 2 exists so you do not have to.

The cap — 3 GB of previews in total, the oldest rotated off

Owner ruling, 2026-10-02 (issue #1868): “for going forward, we should cap the maximum number of previews (<= 10) and rotate old ones off.” Amended 2026-10-04: “Cap by size, not count”, budget “3gb”. Previews had grown to 200–780 MB each, so ten of them came to 3–5 GB and any one lasted an hour or two; a count bounds the total only while preview size holds still, and it did not.

Why a cap rather than a better threshold: GitHub Pages deploys gh-pages as-is, every preview is a full copy of the site (~200–500 MB by then, not the ~38 MB measured above), and nothing bounded how many existed at once. The branch passed GitHub’s 10 GB Pages artifact limit and the live site stopped deploying on 2026-10-01. Every removal path above is per-preview and event-driven; none bounds the total, which is the quantity the limit is about.

What the stage job now does, on every deploy, before its one push:

  • Sums the size of the previews under STAGING/, including the one it is staging, and keeps them within 3 GB. The number lives once, as MAX_PREVIEW_BYTES in cat-harness-tools/scripts/staging-rotate.ts.
  • Keeps the newest that fit and removes the rest, oldest first — strictly by recency. An older small preview never outlives a newer one that did not fit, so a preview’s age alone says whether it is still there. One 780 MB preview displaces several small ones; that is what a size budget means. “Last updated” is the STAGING/<slug>/.staged-at stamp the job writes on every stage, falling back to the latest rendered render-log entry, then the record’s builtAt, then git. The preview being staged is never removed.
  • Never touches STAGING/_retired/ or anything that is not a preview directory.
  • Leaves a record for each removal — a removed render-log entry with the reason, the preview’s staging-preview.json retired into _retired/, and a line in the commit message and the job log giving slug, age and size.

This DOES reach open pull requests, and that is the ruling, not a defect. The paragraphs above say nothing here touches an open PR; under the cap, the oldest preview goes whatever its PR’s state. The ruling is the confirmation deletion-requires-confirmation asks for, given for the class. It is recoverable because a preview is a build, not content: the next push to that PR’s branch re-stages it, and the run that re-stages it can never rotate it off. So when a reviewer’s preview link 404s, check _render-log/ on gh-pages for a removed entry for its slug before calling it broken — if it says “rotated off”, push to the branch (an empty commit will do) or re-run the PR’s Feature Staging workflow.

A closed PR is not an abandoned preview

staging-preview-orphans does not ask “is there an open PR?” any more, and neither should you. A session that reuses one branch across successive pull requests has no open PR for the whole gap between one merging and the next opening — measured at 5m42s on claude/brave-hypatia-r820sf, 2026-09-19, and the sweep landed inside it and called a live branch an orphan. Liveness is three signals, any one of which means leave it alone: an open PR, a branch on the remote carrying work not in the default branch, or a tip commit in the last 30 minutes. scripts/staging-cleanup-preflight.ts --slug <slug> answers the same question on demand, and the dispatch above runs it before removing.

Before you hand a staging URL to a person (STRICT)

A staging URL is not a deliverable until you have checked the ref, and it is never “live” on your say-so. The rule exists because it was broken here: in one session an agent relayed the same preview URL to the owner five times without once checking anything, each time off the back of the bot’s comment — which says a workflow pushed, not that a site serves.

Three states, and conflating any two of them is the defect:

observable from a container?how
the stage job succeededyesthe check run on the PR’s head
the files are in gh-pagesyesread STAGING/<slug>/ on refs/heads/gh-pages
the URL serves themNOsee below

The third is not available to you, and curl will not make it available. The proxy blocks github.io, so a fetch returns 000 and says nothing either way. And where Pages is configured as Deploy from a branch — as it is here — its builds produce no workflow runs at all: measured 2026-09-20, event: page_build returns total_count: 0 and there is no pages-build-deployment workflow to query. So there is no API answer either.

So: verify the ref, and report the third state as unverified rather than inferring it. Listing STAGING/<slug>/ on gh-pages and finding index.html proves the build ran and what it published; it does not prove Pages has rebuilt. Those are different claims and only one of them is yours to make.

LIST the directory. Do not FETCH the page. Found by doing it wrong within minutes of writing this rule: asking for STAGING/<slug>/index.html returns the file, and a staging page here is ~100 KB of rendered HTML — 107,844 characters on the first try, which is a context window spent to learn a fact the directory listing gives for free. The listing returns a name and a byte count per entry; index.html present and non-zero is the whole check. Request only the name and size fields while you are at it.

Say how long, and come back

A person handed a URL with no timing either refreshes a 404 or walks away. Give them the number and the follow-up, in the same breath as the link:

  • The stage job takes ~2 minutes, plus its wait at the rate limit. Measured 2026-09-20 over three consecutive runs: 1m44s, 1m58s, 2m17s. Since #1956 the job then waits until gh-pages has gone 5 minutes without a staging push and 10 without a main-site one (feature-staging §7), and its PR comment says queued with an earliest push time and a live-by time while it waits. Quote those times; they are the job’s own estimate.
  • Pages propagation is on top of that, and GitHub documents it as up to ten minutes. Since it is unobservable from here, quote the bound rather than a guess: “give it ~5 minutes; up to 10 if Pages is slow.”
  • Then actually check back. Schedule it rather than promising it — the same mechanism that watches a PR. Re-list the ref, and if the person reports a 404 after the window, that is a real finding worth chasing; before it, it is the window.

Never present a URL as though it is already serving. “Deployed to gh-pages, should be live in ~5 minutes” is the honest sentence and costs nothing.

Check for newer cleanup commits on gh-pages (bean oz5w)

Before declaring a preview good, check the gh-pages commit log for any staging(cleanup) commit touching that slug that is newer than the last staging(...) publish:

git log -n 5 --oneline origin/gh-pages -- "STAGING/<slug>"

If a staging(cleanup) commit appears newer than the last staging(...) publish, or if the directory was deleted within the hour, flag it immediately. A preview whose slug was recently deleted and re-published is vulnerable to cancelled Pages builds: if Pages cancelled the re-publish build, the site continues serving the deletion (a 404) even though the publish ref has the files.

A preview whose slug was deleted within the hour is not in the ordinary propagation window.

Re-publishing without a code push

When a preview needs to be rebuilt or re-published — because a previous Pages build was cancelled, or because a slug was deleted by cleanup from a preceding PR — you do not need an empty code commit or push. Dispatch the staging workflow directly with the branch name:

gh workflow run feature-staging.yml -f branch=<branch>

This triggers feature-staging.yml via workflow_dispatch for that branch, building and deploying a fresh preview to STAGING/<slug>/ on gh-pages and queueing a new Pages build.

Do not

  • Do not hand over a staging URL without listing STAGING/<slug>/ on gh-pages first. The bot’s comment says a workflow pushed, not that a site serves, and relaying it unchecked is what this section exists for.
  • Do not declare a preview good without checking the gh-pages commit log for newer cleanup commits. A staging(cleanup) commit newer than the publish means a cancelled Pages build leaves the live site serving a 404.
  • Do not say a preview is “live”, “up” or “deployed and ready” unless someone has loaded it. You cannot see that from here.
  • Do not provide before/after URLs without checking the staging workflow has run. A URL that 404s is worse than no URL.
  • Do not auto-remove staging previews. A person confirms every removal — the label while the PR is open, or the dispatch with its repeated-slug confirmation once it is closed.
  • Do not read “no open pull request” as “abandoned”. It is true of a live branch for the whole gap between one PR merging and the next opening.
  • Do not assume all pages are at the root. Guides are under guides/, reference under reference/, French under fr/ etc.
  • Do not omit the comparison table. Even for a single page change, show the before/after pair — it is the whole point of staging.

Before you report a staging URL as broken

Look at the publish ref, not the site. A staging URL is LOOKED UP in gh-pages under STAGING/<branch-slug>/, never composed from the source path — docs/<stub>/proposals/x.md publishes to /proposals/x.html, and composing it from the source path yields a 404 for a page that is there. From an agent container a curl against a Pages URL fails on the proxy regardless, so a failed fetch is not evidence either way.

Full rule and the measured failure: github-state-inspection.

Processes that run this skill

processstep(s) that name it
Review taskClassify what changed
CRDM Phase 6 — implement, MVP, acceptancePost summary to issue; Share MVP with stakeholders
Code change and reviewCommit, push, open the PR; Review the change
Staging a feature branch preview, and taking it downComment the preview URL on the PR; Post the retention notice on the PR
Adopting an upstream version bumpReview the MVP against what we bind to
Questionnaire wireframe reviewAsk for review with deep links
Authoring a document8 · Review and feedback
Content Change and ReviewReview staged rendering; Request further revisions; Submit to review committee; Compare main vs staging; Slice the change and assign reviewers
Draft, review and publishCirculate the draft; Review the draft publication; Clinical / scientific sign-off
Public comment on a review draftReview the change set on its staging preview (calls a sub-process)
L2 component edit and reviewAsk for review with staging deep links
SMART Guideline end to endReview each L2 asset (before / after if adapted) (calls a sub-process); Review each L3 asset (before / after if adapted) (calls a sub-process); Adjudicate the L4 wireframes and application (calls a sub-process)

folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed dev