⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
post-launch
The moment DNS moves and for the thirty days after — verifies the cutover (DNS propagation, certificate, http and www redirects, the noindex, robots and sitemap on the live host, every redirect the audit mapped, forms, analytics, the staging copy, the old host, security headers), diffs production against the pre-launch baseline, and schedules the watch. Use when asked did the launch work, is the site live, what should I check after launch, the domain just switched, or when a site went live in the last month and something looks wrong. It is read-only and exits 0 whatever it finds.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
astro-conversion-kit
Convert a WordPress site to static Astro with your coding agent, and get back the same site: the same URLs, the same content, the same titles, descriptions, canonicals and structured data, the same media at the same paths, every redirect the old site performs, and a design checked pixel by pixel at 390, 768 and 1440 px. No PHP, no plugins, no database, no server to patch.
It is a set of Agent Skills, so it runs in Claude Code, Codex or any agent that reads SKILL.md.
Plain Node scripts do everything that has to be exact, and gates prove each part before the work
moves on. A run ends with a pull request, a noindexed staging deploy and a launch runbook.
WordPress is the first-class case. Anything served over HTTP works, because the capture reads the rendered HTML; see docs/platforms.md.
Start
Paste this into your coding agent, with your site in place of example.com:
Convert https://example.com to Astro with https://github.com/ethanplusai/astro-conversion-kit
That is the whole job. The agent installs the skills, asks you one message of questions (each with a stated default: where the repo goes, which host, how forms are handled), and runs to a pull request without stopping again. Answer "defaults" and it picks every default.
To install the skills first, into any agent that reads Agent Skills:
npx skills add ethanplusai/astro-conversion-kit
In Claude Code, from its plugin marketplace:
/plugin marketplace add ethanplusai/astro-conversion-kit
/plugin install astro-conversion@astro-conversion-kit
Or from a clone, which also lets you run any script by hand:
git clone https://github.com/ethanplusai/astro-conversion-kit
cd astro-conversion-kit
npm install
npx playwright install chromium
node skills/convert-site/scripts/preflight.mjs
preflight.mjs says which tools the run needs (Node, git, gh, the hosting CLI, the Playwright
browser) and whether each is installed and logged in. Every script prints --help.
Results

Averages over 45 WordPress conversions run with the kit, intake to pull request. Every run passed the pixel-parity review at all three widths.
Demos
Public WordPress sites converted by a coding agent following the skills as published. Each link is the Astro build on Vercel; each image is the live WordPress page beside the Astro build. Counts and timings are in docs/examples.
sxsw.com (WordPress VIP, block theme, 71 pages): https://sxsw-com-astro-v2.vercel.app

harvard.edu (WordPress multisite, 457 pages): https://harvard-edu-astro.vercel.app

angrybirds.com (WordPress on WP Engine, 388 pages, media in Vercel Blob): https://angrybirds-com.vercel.app

What happens
- Intake. At most four requests fingerprint the platform; one message asks you where the repo and hosting go, how forms are handled, and what the agent cannot detect.
- Capture. A paced, read-only crawl (sitemaps, the WordPress REST API, links) saves every
page's HTML, head tags, redirects, media list, fonts, feeds and screenshots into
capture/. - Plan.
docs/plan.md: templates, counts, the media decision, and a row for every oddity. - Scaffold and baseline. A new Astro repo on an
astro-rebuildbranch, with the capture frozen intobaseline/so the gates never need the old site again. - Build. Layouts first, then pages from the captured markup, the captured CSS pruned by coverage, media at the original paths, forms and third-party scripts carried over by list.
- Gates. Routes, redirects, SEO tags, visible text, links, media, sitemap, feeds, forms and scripts, checked against the baseline on every commit.
- Visual review. Live and new screenshots per template at three widths, pixel-diffed, plus a computed-style check, until every pair passes; then every page at 390 and 1440.
- Launch checks.
pre-launchon the repo, a staging deploy,launch-auditagainst staging, a suggestions report, and the PR. You merge and launch;post-launchwatches the first 30 days.
docs/process.md says what each phase produces and what proves it done.
What you get
- A pull request from
astro-rebuildtomainin a repo you own, with the gate output, counts, every exception and proposal, and the launch skills' "Not covered" sections quoted in full. - A noindexed staging deploy on Vercel or Cloudflare Pages (or a local preview and a first runbook step to deploy, if you have no hosting account yet).
- Contact sheets: live, new and diff screenshots side by side for every template and width.
docs/suggestions.md: broken links, missing alt text, duplicate titles, slow images and other improvements the agent found but did not make, each with evidence, effort and risk.docs/LAUNCH.md: the runbook, in order, with the exact commands for your host.npm run verifyin the new repo, so the gates keep working after the old site is gone.
The default build reproduces the live site, faults included. Fixes are proposals you approve.
WordPress, specifically
The capture needs no admin login, plugin or database export; it reads what the site serves.
- Every public page and post, from the sitemaps, the REST API (
wp/v2/pages,posts,media,categories,tags) and the links between pages, so nothing the sitemap forgot is missed. Drafts and private content never appear; the API only returns what is public. - Every image size. The resized copies WordPress writes (
photo-300x200.jpg, ...) and lists insrcsetall stay at their paths, because other sites and old emails may link to any of them. - Media that fits the repo stays in it; larger libraries go to Vercel Blob or Cloudflare R2 at the same URLs, with the decision made per site and written into the plan.
?p=shortlinks, category-base collisions, shortcodes left in content, lazy-load attributes, and the other WordPress habits, handled as listed in docs/platforms.md.- Classic themes, page builders and block themes. The build keeps the theme's CSS, pruned to the rules the pages use, and the per-page inline styles block themes emit.
- Forms post to a third-party endpoint you name at intake. Analytics, tag managers, consent banners and chat widgets are carried over by list, so nothing is silently dropped.
- Redirects the old site performs (including
wwwand trailing-slash rules) become host rules, and the launch skills check that every old URL still lands somewhere.
What a static site cannot do (site search, comments, carts, logins, user-generated content) is found at intake and reported as out of scope with the options: a third-party embed, keeping that part on the old platform under a subdomain, or a separate project.
What it refuses
To run on end-of-life Node; to submit forms or log in on the live site; to touch DNS; to commit
anything but the scaffold to main (all work goes on astro-rebuild and reaches main through
a pull request you merge); to soften a gate. One site per run.
Security
The converted site is static HTML, CSS, images and the scripts you keep. There is no PHP and no Node running on your server, so a runtime vulnerability has nothing to attack there. The build tooling runs Node on your machine and in CI and has to stay patched; third-party scripts you keep are their
// HOW IT'S BUILT
// FILES
11 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/post-launch/SKILL.md9.0 KB60164b904c9dupstream ↗
--- name: post-launch description: The moment DNS moves and for the thirty days after — verifies the cutover (DNS propagation, certificate, http and www redirects, the noindex, robots and sitemap on the live host, every redirect the audit mapped, forms, analytics, the staging copy, the old host, security headers), diffs production against the pre-launch baseline, and schedules the watch. Use when asked did the launch work, is the site live, what should I check after launch, the domain just switched, or when a site went live in the last month and something looks wrong. It is read-only and exits 0 whatever it finds. --- # post-launch The watch. It runs the moment DNS has moved, and then once a day for thirty days. It reports; it never gates, never fixes, and never announces anything. ## The one rule **Blockers are read first and announced last.** Nothing about a launch is announced — no email, no post, no client call — until this report comes back clean. An announcement is the only step of a launch that cannot be taken back, and the two blockers here are exactly the two states where the launch has not really happened: a certificate nobody can get past, and a noindex telling search engines to forget the site. Both look fine in a browser you are already logged into. If somebody asks you to announce while a blocker stands, say plainly what the blocker is and what it costs, and do not soften it. ## Read-only The skill is read-only. It fetches the live site, the staging host, the old host and the urls in the redirect map, and it asks four public DNS resolvers where the domain points. It writes exactly two things, both the operator's own record: the report bundle, and `watch.json` inside it. It never edits a site, submits a form, flips a setting, calls a provider API or touches DNS. Three rules follow, and you restate them when you report: 1. **Evidence for every finding.** A url, a status, a header, a DNS answer, a csv line. 2. **A skip is not a pass.** Every input you did not supply becomes a named line in **Not covered**. 3. **`Not covered` is never a pass.** You repeat that section to the human verbatim. ## The two modes | Mode | When | What it answers | |---|---|---| | `--mode cutover` | the moment DNS has moved, and after any fix | did the launch actually happen | | `--mode watch` | once a day, days 1–30 | what is due today, and has anything drifted | Run `cutover` first, fix what it finds, run it again until it is clean, **then** announce. Start the watch the same day. ## 1. Gather the inputs Every input you leave out becomes a check that cannot run. Ask for each one; do not guess. - **The production url.** Required. - **`baseline.json`** — from the `pre-launch` bundle, or from a `launch-audit` run against staging before the cutover. This is what "did anything change" is measured against. Without it, four checks have nothing to compare and a fifth falls back to `--expect-analytics`. All of them say so. - **`redirects.csv`** — from the `launch-audit` bundle built with `--old-url`. Every row is re-requested at the url it was written with. - **The old site's url**, if one is being replaced. - **The staging url**, so the run can confirm the staging copy is now sealed. - **The analytics measurement id**, for when there is no baseline. - **The DNS records that were supposed to be in place**, as `apex=` and `www=` values. If you do not have the baseline or the redirect map, say so before you run rather than after: they come from the two earlier phases, and after the cutover neither can be recreated. ## 2. Run the cutover check Run the script; never read its source, and never reimplement what it does. node scripts/post-launch.mjs --mode cutover --url <production url> \ --baseline <path to baseline.json> --redirects <path to redirects.csv> \ --old-url <old site url> --staging-url <staging url> \ --expect-analytics <measurement id> --expect-dns apex=<ip>,www=<host> --out <dir> The path is relative to this skill's own directory — run the command from there. The bundle is written under the **current working directory**: without `--out` it lands in `./website-launch/<host>-post-launch-<date>/`, which with the command above means inside this skill's own directory. Write the bundle wherever the work lives with `--out`. Other flags: `--dev-host <host>` (repeatable), `--browser auto|on|off`, `--out <dir>`, `--config <path>`, `--json`, `--help`. **Exit codes:** `0` always, however bad the cutover is. `2` for a usage error or an IO failure. Exit 0 never means "the launch is fine" — this reports, it does not gate. Read the report. ## 3. Read the report in this order `references/reading-the-report.md` has the bundle layout. `references/cutover-day.md` explains why the day runs in the order it does — read it before you start fixing things out of order. 1. **The blockers.** Printed bolded, first, whatever step of the day they came from. Fix these before anything else and before anybody is told the site is live. 2. **The high findings.** Redirect drift, a missing page, a dead form endpoint, a live staging copy, the old host still serving — each of these is a thing losing traffic or enquiries right now. They are today's work, not this week's. 3. **The baseline diff.** `references/baseline-diff.md` says which fields are expected to move at a launch and which never are. Do not report the whole diff as damage; report the parts nobody asked for. 4. **Medium and info.** Read them. Say what they are. Do not fix them today. 5. **Not covered, verbatim.** Every line, unedited, including the two things no crawl can see. Then re-run, and keep re-running until the blockers are gone. ## 4. Work the six human items They print at the end of every run and they are not optional: submit the sitemap (Search Console and Bing) · request indexing for the top pages confirm the new Search Console property · verify analytics in real time keep the old host up and redirecting · announce only after this is clean Never tick one off on somebody's behalf. "The endpoint returned 200" is not "a person received the test submission", and "the tag is in the HTML" is not "a hit appeared in real time". ## 5. Run the watch, daily node scripts/post-launch.mjs --mode watch --url <production url> --day <n> It prints what is overdue, what is due today and what is coming in the next seven days, then re-runs the automated part and records the run. `references/thirty-day-watch.md` has the whole schedule, what to look at in Search Console and analytics on each day, and what "healthy" looks like so a normal post-launch dip is not reported as a disaster. Leave `--day` off and it counts from the first run. A day the watch never ran stays overdue for the automated tasks until a run covers it; a human task stays overdue until a human clears it, because nothing a script does is evidence that somebody opened Search Console. ## 6. Day 30 Two things, and neither is automated: - **The retrospective.** What regressed, what the redirect map missed, and what to add to the `pre-launch` gate so the next launch does not repeat it. Write it down. - **The old-host decision.** The old host may be decommissioned only once Search Console shows no crawl of old urls **and** the redirects have been in place for at least thirty days. Until both are true, keep paying for it. Turning it off early throws away every link the old site earned. ## 7. Present the result Lead with the decision, not a count. 1. **The verdict.** "The cutover has one blocker" or "The cutover is clean." One sentence. 2. **What blocks it**, with the fix named. 3. **What is bleeding**, grouped by cause: the high findings, in plain language. 4. **What changed against the baseline** that nobody asked for. 5. **Not covered, verbatim.** 6. **The human items**, with what is done and what is not. 7. **Whether the launch may be announced.** Say it explicitly, either way. ## The two checks that never run - **Copy vs. source documents.** Nothing compares the words on the live pages against the signed-off copy. A person has to read both. - **Pages vs. agreed scope.** Nothing knows which pages were supposed to exist. A page that was never built is invisible here. ## References | File | Covers | |---|---| | `references/cutover-day.md` | The ordered day script, and why each step sits where it does | | `references/thirty-day-watch.md` | The schedule, what to read in Search Console and analytics, what healthy looks like, the old-host rule | | `references/baseline-diff.md` | Every diff field, which changes are expected at a launch and which are not | | `references/reading-the-report.md` | The bundle, the severities, the ids, how to triage | ## Where this sits `pre-launch` gates the repository before deploy and blocks. `launch-audit` crawls a staging or live url and reports. `post-launch` is this one: the cutover and the thirty days after it. Run them in that order — the baseline and the redirect map this skill needs can only be made by the first two, and only while the old site is still up.skills/post-launch/scripts/lib/checks/cutover.mjs46 KB48dbdec58bc2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/post-launch/scripts/lib/cutover.mjs26 KB26cce781b2d2upstream ↗
// Cutover day: build the context, run the checks, write the bundle. // // `buildCutoverContext` is the launch-audit context plus the six facts only cutover day has, each // of them fetched once here so the checks stay pure: // // ctx.baseline / ctx.baselineDiff the pre-launch baseline, and production diffed against it // ctx.redirectRows the audit's redirects.csv, parsed // ctx.cutoverRedirects every one of those old urls, re-requested AS IT WAS WRITTEN // ctx.expectDns / ctx.dnsAcross the expected records, and what four public resolvers answer // ctx.stagingProbe the staging host and its robots.txt // ctx.oldRootProbe the old host's root // ctx.formEndpoints every same-origin form action a crawled page names // // The redirect rows are re-requested at their original absolute urls rather than rewritten onto // the production origin, because that is the actual question on the day: after DNS moved, what // happens to the url somebody has in a bookmark? Rewriting the host would test the new site's // routing and say nothing about the redirect a visitor actually follows. // // An input that cannot be read is a coverage gap, never a crash: an unreadable baseline, a // malformed csv row, a probe set that hit its cap — each one lands in `Not covered` by name and // the rest of the run continues. The report must be able to say "we did not compare" rather than // implying a clean diff nobody performed. import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { buildAuditContext } from '../../../launch-audit/scripts/lib/checks/_ctx.mjs' import { parseRobots } from '../../../launch-audit/scripts/lib/robots.mjs' import { parseRedirectCsv } from '../../../launch-audit/scripts/lib/csv.mjs' import { makeBaseline, diffBaseline } from '../../../launch-audit/scripts/lib/baseline.mjs' import { detectAnalytics } from '../../../launch-audit/scripts/lib/detect/analytics.mjs' import { createRegistry, SkipCheck } from '../../../launch-audit/scripts/lib/registry.mjs' import { validateFinding, sortFindings } from '../../../launch-audit/scripts/lib/findings.mjs' import { buildNotCovered, insertBeforeFixed, inline, writeBundle, resolveOutDir, screenshotNames, } from '../../../launch-audit/scripts/lib/report.mjs' import { slugify } from '../../../launch-audit/scripts/lib/slug.mjs' import { checks as cutoverChecks, CUTOVER_ORDER, VERIFIABLE } from './checks/cutover.mjs' export const PHASE = 'post-launch' // A site can carry a lot of old urls. Five hundred is more than any hand-written redirect map has // and small enough to finish while somebody watches; anything past it is named in "Not covered". const REDIRECT_PROBE_CAP = 500 const FORM_ENDPOINT_CAP = 100 // The response headers a baseline records, and therefore the ones a regression is measured // against. Same list launch-audit writes, because the two have to be comparable. const BASELINE_SECURITY_HEADERS = [ 'strict-transport-security', 'content-security-policy', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy', 'cross-origin-opener-policy', 'cross-origin-resource-policy', ] const EXPECT_DNS_KEYS = ['apex', 'www'] export const messageOf = (error) => (error?.message === undefined || error.message === '' ? String(error) : error.message) // ------------------------------------------------------------------------------- the inputs // `--expect-dns apex=1.2.3.4,www=cname.host` → `{apex: ['1.2.3.4'], www: ['cname.host']}`. A key // may repeat, because a host with two A records has two expected answers. Anything that is not // `apex=` or `www=` is a usage error rather than a silently ignored token: a typo here would make // the check pass by having nothing to compare. export function parseExpectDns(value) { if (typeof value !== 'string' || value.trim() === '') return null const out = { apex: [], www: [] } for (const token of value.split(',')) { const piece = token.trim() if (piece === '') continue const eq = piece.indexOf('=') if (eq === -1) throw new Error(`--expect-dns expects apex=<value> or www=<value>, got "${piece}"`) const key = piece.slice(0, eq).trim().toLowerCase() const target = piece.slice(eq + 1).trim() if (!EXPECT_DNS_KEYS.includes(key)) throw new Error(`--expect-dns key must be apex or www, got "${key}"`) if (target === '') throw new Error(`--expect-dns ${key}= has no value`) out[key].push(target) } if (out.apex.length === 0 && out.www.length === 0) return null return out } // `options.baseline` is a path, `options.baselineData` an already-parsed baseline (which is what // a watch run hands back in). Either may be absent; neither is allowed to throw. function readBaseline(options, gaps) { if (options.baselineData !== undefined && options.baselineData !== null) return options.baselineData if (typeof options.baseline !== 'string' || options.baseline === '') { gaps.push('Baseline comparison: not run — no --baseline was given, so nothing was compared against the site as it was before the launch') return null } try { return JSON.parse(readFileSync(options.baseline, 'utf8')) } catch (error) { gaps.push(`Baseline comparison: skipped — ${messageOf(error)}`) return null } } function readRedirectRows(options, gaps) { if (typeof options.redirectsCsv !== 'string' || options.redirectsCsv === '') { gaps.push('Redirect drift: not run — no --redirects was given, so no old url was re-requested') return null } let text try { text = readFileSync(options.redirectsCsv, 'utf8') } catch (error) { gaps.push(`Redirect drift: skipped — ${messageOf(error)}`) return null } const { rows, errors } = parseRedirectCsv(text) for (const error of errors) gaps.push(`Redirect map: ${error}`) if (rows.length === 0 && errors.length === 0) gaps.push('Redirect map: the csv has a header and no rows') const unverifiable = rows.filter((row) => !VERIFIABLE.has(row.status_on_new)) if (unverifiable.length > 0) { gaps.push(`Redirect drift: ${unverifiable.length} row${unverifiable.length === 1 ? '' : 's'} could not be verified — the audit recorded them as "${[...new Set(unverifiable.map((row) => row.status_on_new))].join('", "')}", which promises no destination to check`) } return rows } // ------------------------------------------------------------------------------- the context function analyticsByPage(crawl) { const byUrl = new Map() for (const entry of crawl.pages) { const body = typeof entry.response?.body === 'string' ? entry.response.body : '' byUrl.set(entry.url, [...new Set(detectAnalytics(body).map((hit) => hit.name))]) } return byUrl } function securityHeadersOf(ctx) { const home = ctx.crawl.pages.find((p) => p.url === ctx.target.url) ?? ctx.crawl.pages[0] const headers = home?.response?.headers ?? {} const out = {} for (const name of BASELINE_SECURITY_HEADERS) if (headers[name] !== undefined) out[name] = headers[name] return out } // The baseline this run of production would leave behind, built exactly the way launch-audit // builds one so the two are comparable field for field. export function productionBaseline(ctx, at) { return makeBaseline(ctx.crawl, { now: at, securityHeaders: securityHeadersOf(ctx), cert: ctx.cert === null ? null : { expires: ctx.cert.validTo, daysLeft: ctx.cert.daysLeft }, robotsHash: ctx.robots.text === null ? null : createHash('sha256').update(ctx.robots.text).digest('hex'), sitemapUrls: ctx.sitemaps.urls, analyticsByPage: analyticsByPage(ctx.crawl), }) } // Every same-origin form action a crawled page names, absolute and deduped. An off-origin action // is a third-party form, which arrives through `probes.embeds` instead. function formActionUrls(ctx) { const out = new Set() for (const entry of ctx.crawl.pages) { if (entry.page === null) continue for (const form of entry.page.forms) { const action = form.action if (typeof action !== 'string' || action === '' || action === '#') continue let parsed try { parsed = new URL(action) } catch { continue } if (parsed.origin !== ctx.target.origin) continue out.add(parsed.href) } } return [...out] } const capped = (items, cap) => ({ items: items.slice(0, cap), caps: { cap, dropped: Math.max(0, items.length - cap) } }) export async function buildCutoverContext({ net, dns, options = {}, now = new Date() } = {}) { const at = now instanceof Date ? now : new Date(now) const gaps = [] const ctx = await buildAuditContext(net, dns, { ...options, url: options.url, oldUrl: options.oldUrl, now: at }) // The staging host, as a host rather than a url, because every "is this reference production?" // question below compares hosts. let stagingUrl = null let stagingHost = null if (typeof options.stagingUrl === 'string' && options.stagingUrl !== '') { try { const parsed = new URL(options.stagingUrl) stagingUrl = parsed.href stagingHost = parsed.hostname.toLowerCase() } catch { gaps.push(`Staging host: skipped — --staging-url is not a url: ${options.stagingUrl}`) } } else { gaps.push('Staging host: not checked — no --staging-url was given, so nothing confirmed the staging copy is sealed') } ctx.stagingHost = stagingHost // 1. the baseline, and production diffed against it ctx.baseline = readBaseline(options, gaps) ctx.productionBaseline = productionBaseline(ctx, at) ctx.baselineDiff = null if (ctx.baseline !== null) { try { ctx.baselineDiff = diffBaseline(ctx.baseline, ctx.productionBaseline) } catch (error) { gaps.push(`Baseline comparison: skipped — ${messageOf(error)}`) } } // 2. the redirect map, re-requested as written ctx.redirectRows = readRedirectRows(options, gaps) ctx.cutoverRedirects = new Map() const redirectPick = capped( (ctx.redirectRows ?? []).filter((row) => VERIFIABLE.has(row.status_on_new)).map((row) => row.old_url), REDIRECT_PROBE_CAP, ) if (redirectPick.items.length > 0) { const responses = await net.map(redirectPick.items, (oldUrl) => net.get(oldUrl)) redirectPick.items.forEach((oldUrl, i) => ctx.cutoverRedirects.set(oldUrl, responses[i])) } ctx.probes.caps.cutoverRedirects = redirectPick.caps // 3. DNS: what was expected, and what the public resolvers actually answer ctx.expectDns = options.expectDns ?? null if (ctx.expectDns === null) { gaps.push('DNS targets: not checked — no --expect-dns was given, so nothing compared the live records against the intended ones') } ctx.dnsAcross = null if (typeof dns.resolveAcross === 'function') { const hosts = [ctx.target.apex, ...(ctx.target.hasWwwCounterpart ? [ctx.target.www] : [])] const answers = await Promise.all(hosts.map((host) => dns.resolveAcross(host))) ctx.dnsAcross = Object.fromEntries(hosts.map((host, i) => [host, answers[i]])) } else { gaps.push('DNS propagation: not sampled — this run has no resolver adapter, so no public resolver was asked') } // 4. staging, and its own robots.txt ctx.stagingProbe = null if (stagingUrl !== null) { const response = await net.get(stagingUrl) const robotsUrl = `${new URL(stagingUrl).origin}/robots.txt` const robotsResponse = await net.get(robotsUrl) const robotsText = robotsResponse.status === 200 && typeof robotsResponse.body === 'string' ? robotsResponse.body : null ctx.stagingProbe = { url: stagingUrl, response, robots: { url: robotsUrl, status: robotsResponse.status, text: robotsText, parsed: robotsText === null ? null : parseRobots(robotsText) }, } } // 5. the old host's root ctx.oldRootProbe = null if (typeof options.oldUrl === 'string' && options.oldUrl !== '') { ctx.oldRootProbe = await net.get(options.oldUrl) } else { gaps.push('Old host: not checked — no --old-url was given, so nothing confirmed the site being replaced now redirects') } // 6. every same-origin form endpoint. A response the crawl already has is reused rather than // fetched twice; the rest are fetched with GET, which is read-only — nothing here submits. const byCrawl = new Map() for (const entry of ctx.crawl.pages) { byCrawl.set(entry.url, entry.response) if (entry.finalUrl && !byCrawl.has(entry.finalUrl)) byCrawl.set(entry.finalUrl, entry.response) } const formPick = capped(formActionUrls(ctx), FORM_ENDPOINT_CAP) const toFetch = formPick.items.filter((endpoint) => !byCrawl.has(endpoint)) const fetched = await net.map(toFetch, (endpoint) => net.get(endpoint)) const fetchedByUrl = new Map(toFetch.map((endpoint, i) => [endpoint, fetched[i]])) ctx.formEndpoints = new Map(formPick.items.map((endpoint) => [endpoint, byCrawl.get(endpoint) ?? fetchedByUrl.get(endpoint)])) ctx.probes.caps.formEndpoints = formPick.caps ctx.gaps = gaps return ctx } // -------------------------------------------------------------------------------- the browser const unavailableTier = (reason) => ({ available: false, reason, renders: new Map() }) // The browser tier a cutover needs, which is one page: the start url. launch-audit renders six for // template variety; cutover day has one question — does the tag fire on the page people land on. export async function renderStartPage(ctx, browser) { if (browser === null || browser === undefined) { return { tier: unavailableTier('no browser adapter was supplied to this run'), lines: [] } } let opened = false try { opened = browser.available === true if (!opened && typeof browser.open === 'function') opened = (await browser.open()) === true } catch (error) { return { tier: unavailableTier(`the browser tier failed to open — ${messageOf(error)}`), lines: [] } } if (!opened) return { tier: unavailableTier(browser.reason ?? 'the browser tier did not open'), lines: [] } if (typeof browser.render !== 'function') { return { tier: unavailableTier('the browser adapter cannot render a page'), lines: [] } } const renders = new Map() const lines = [] try { renders.set(ctx.target.url, await browser.render(ctx.target.url)) } catch (error) { lines.push(`Browser tier: ${ctx.target.url} — render failed — ${messageOf(error)}`) } return { tier: { available: true, reason: null, renders }, lines } } // --------------------------------------------------------------------------------- the checks export function cutoverRegistry(extraModules = []) { const registry = createRegistry() for (const check of cutoverChecks) registry.add(check) for (const module of extraModules) for (const check of module.checks) registry.add(check) return registry } // Unwraps whichever of the two return shapes a check used — the same pair pre-launch's gate runner // accepts, and for the same reason. A check that looked and found nothing returns `Finding[]`. A // check that could not look at all returns `{findings, notCovered}`, and those lines go straight // into the report's Not covered section: "the certificate was never read" and "the certificate is // fine" are different answers, and only the first one has somewhere honest to go. // `null` means the check returned neither, which is a bug in the check. // A `notCovered` that is not an array of strings used to be filtered down to `[]`, which deleted // the one thing this shape exists to carry and did it without a word. A check that tried to // declare a coverage gap and got the shape wrong has not declared nothing — it has failed to // declare something, and those are opposite facts. An omitted `notCovered` is neither: the check // simply had no bound to report. const MALFORMED_NOT_COVERED = { malformed: 'malformed notCovered' } const MALFORMED_SHAPE = { malformed: 'run() returned neither an array of findings nor { findings, notCovered }' } function unwrap(produced) { if (Array.isArray(produced)) return { findings: produced, notCovered: [] } if (produced !== null && typeof produced === 'object' && Array.isArray(produced.findings)) { const declared = produced.notCovered if (declared === undefined || declared === null) return { findings: produced.findings, notCovered: [] } if (!Array.isArray(declared) || !declared.every((line) => typeof line === 'string')) return MALFORMED_NOT_COVERED return { findings: produced.findings, notCovered: declared.filter((line) => line !== '') } } return MALFORMED_SHAPE } // Runs every check in `registry` over `ctx`. A check that throws costs that check and says so; a // finding that fails validation is dropped and says so. Same contract as the auditor, for the same // reason: a report that is quietly short one check is worse than one that names the gap. export async function runChecksOver(ctx, registry) { const knownIds = registry.ids() const findings = [] const lines = [] const skipped = [] for (const check of registry.list()) { let produced try { produced = await check.run(ctx) } catch (error) { if (error instanceof SkipCheck) skipped.push({ check: check.id, reason: error.reason }) else lines.push(`${check.id}: check crashed — ${messageOf(error)}`) continue } const unwrapped = unwrap(produced) if (unwrapped.malformed !== undefined) { lines.push(`${check.id}: check crashed — ${unwrapped.malformed}`) continue } lines.push(...unwrapped.notCovered) for (const candidate of unwrapped.findings) { const { ok, errors } = validateFinding(candidate, knownIds) if (!ok) { lines.push(`${check.id}: finding dropped as invalid — ${errors.join('; ')}`) continue } findings.push(candidate) } } return { findings, lines, skipped } } // One line per capped probe set that actually dropped something. export function capLines(caps) { const lines = [] for (const [set, cap] of Object.entries(caps ?? {})) { if (typeof cap?.skipped === 'string') lines.push(`${set}: not probed — ${cap.skipped}`) const dropped = cap?.dropped ?? 0 if (dropped > 0) lines.push(`${set}: ${dropped} of ${dropped + cap.cap} not probed (cap ${cap.cap})`) } return lines } // ---------------------------------------------------------------------------------- the report const SEVERITY_RANK = { blocker: 0, high: 1, medium: 2, low: 3, info: 4 } function formatValue(v) { if (v === null || v === undefined) return '(none)' if (typeof v === 'string') return inline(v) if (typeof v === 'number' || typeof v === 'boolean') return String(v) try { return inline(JSON.stringify(v)) } catch { return '(none)' } } // A blocker is bolded wherever it lands in the day order, because the day order is a dependency // order and not a severity order: the one thing that must be read first has to be visible while // somebody is reading step six. function renderFinding(f) { const heading = f.severity === 'blocker' ? `### **BLOCKER** — ${inline(f.title)}` : `### [${f.severity}] ${inline(f.title)}` const lines = [heading, ''] lines.push(`- Page: ${inline(f.page || 'site-wide')}`) lines.push(`- Detail: ${inline(f.detail)}`) lines.push('- Evidence:') for (const e of f.evidence) lines.push(` - ${inline(e.type)}: ${formatValue(e.value)}`) lines.push(`- Fix: ${inline(f.fix)}`) return lines } // The cutover section in the order of the day (spec §6.1), and anything else — the audit // dimensions a watch day re-runs — after it, grouped by dimension and severity-ranked within. export function renderCutoverReport({ target, generatedAt, findings = [], notCovered = [], summary, plan = null }) { const lines = [] lines.push(`# Launch post-launch report — ${inline(target)}`, '') lines.push(`Generated ${generatedAt}`, '') lines.push('| Pages | Blockers | High | Medium | Low | Info |') lines.push('| --- | --- | --- | --- | --- | --- |') lines.push(`| ${summary.pages} | ${summary.blockers} | ${summary.high} | ${summary.medium} | ${summary.low} | ${summary.info} |`, '') const order = new Map(CUTOVER_ORDER.map((id, i) => [id, i])) const cutover = findings.filter((f) => f.dimension === 'cutover') .sort((a, b) => (order.get(a.id) ?? 999) - (order.get(b.id) ?? 999) || String(a.page).localeCompare(String(b.page))) lines.push('## Cutover verification', '') if (cutover.length === 0) { lines.push('_No cutover findings._', '') } else { lines.push( 'In the order the day depends on: DNS, transport, indexability, robots and sitemap, redirects,', 'the baseline diff, analytics, forms, staging, the old host, headers, people.', '', ) for (const finding of cutover) lines.push(...renderFinding(finding), '') } const rest = findings.filter((f) => f.dimension !== 'cutover') .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.dimension.localeCompare(b.dimension) || String(a.page).localeCompare(String(b.page))) const dims = [] const byDim = new Map() for (const f of rest) { if (!byDim.has(f.dimension)) { byDim.set(f.dimension, []) dims.push(f.dimension) } byDim.get(f.dimension).push(f) } for (const dim of dims) { lines.push(`## ${inline(dim)}`, '') for (const f of byDim.get(dim)) lines.push(...renderFinding(f), '') } if (plan !== null) { lines.push('## The watch', '') for (const entry of plan) { lines.push(`- [${entry.due}] day ${entry.day}: ${inline(entry.task)}${entry.automated ? ' _(automated)_' : ''}`) } lines.push('') } lines.push('## Ops & access readiness', '') lines.push('- [human] The ops checklist belongs to the launch-audit bundle, which is the run that has the evidence for it. Nothing here re-verifies those items, and nothing here ticks one off.', '') lines.push('## Not covered', '') for (const line of notCovered) lines.push(`- ${inline(line)}`) return lines.join('\n') + '\n' } export function summaryOf(findings, pages) { const count = (severity) => findings.filter((f) => f.severity === severity).length return { pages, blockers: count('blocker'), high: count('high'), medium: count('medium'), low: count('low'), info: count('info'), } } // The four dimensions a full launch-audit has that cutover day does not re-run. `--mode watch` // adds exactly these back (watch.mjs WATCH_MODULES), which is why the line below names that mode // as the way to get them. const AUDIT_DIMENSIONS_A_WATCH_ADDS = ['links', 'head', 'indexability', 'inventory'] const andList = (items) => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`) // How narrow this run was, in one sentence, computed from the registry that actually ran. // // A cutover report is the one a person reads immediately before announcing a launch, and nothing // in it said that it covers 21 conditions out of a full audit's 176. A clean cutover report // therefore read like a clean site. The smoke run found this from the other end: day 1 of the // watch surfaced two high findings the cutover the evening before had not, because the cutover // never looked. A watch day does re-run those dimensions, so it gets no such line — the sentence // is about what was left out, and on a watch day nothing was. export function scopeLine(registry = null) { const checks = registry === null ? cutoverChecks : registry.list() const conditions = checks.filter((check) => check.dimension === 'cutover').length const present = new Set(checks.map((check) => check.dimension)) const missing = AUDIT_DIMENSIONS_A_WATCH_ADDS.filter((dimension) => !present.has(dimension)) if (missing.length === 0) return null return `This run checked the ${conditions} cutover conditions only; the full audit’s ${andList(missing)} dimensions were not re-run — use --mode watch for those.` } // The "Not covered" section for a post-launch run: the engine's standard lines, then everything // this run could not do, then the two things no crawl can ever see. export function cutoverNotCovered(ctx, { tier, skipped = [], ownLines = [], browserLines = [], registry = null }) { let notCovered = buildNotCovered({ dropped: ctx.crawl.dropped ?? [], browserAvailable: tier.available, browserReason: tier.reason, skipped, ignoredRobots: ctx.options.ignoreRobots === true, psiSkipped: null, }) const scope = scopeLine(registry) return insertBeforeFixed(notCovered, [ ...ownLines, ...(ctx.gaps ?? []), ...capLines(ctx.probes.caps), ...browserLines, ...(scope === null ? [] : [scope]), ]) } // ------------------------------------------------------------------------------------ the run export function cutoverOutDir({ options, now, cwd }) { return resolveOutDir({ out: options.out, slug: slugify(new URL(options.url).hostname), phase: PHASE, now, cwd, }) } export async function runCutover({ net, dns, browser = null, options = {}, now = new Date(), cwd = process.cwd() } = {}) { const at = now instanceof Date ? now : new Date(now) try { const ctx = await buildCutoverContext({ net, dns, options, now: at }) const { tier, lines: browserLines } = await renderStartPage(ctx, browser) ctx.browser = tier const registry = options.registry ?? cutoverRegistry() const { findings, lines, skipped } = await runChecksOver(ctx, registry) const sorted = sortFindings(findings) const notCovered = cutoverNotCovered(ctx, { tier, skipped, ownLines: lines, browserLines, registry }) const summary = summaryOf(sorted, ctx.crawl.pages.length) const report = renderCutoverReport({ target: ctx.target.url, generatedAt: at.toISOString(), findings: sorted, notCovered, summary, }) const bundleDir = cutoverOutDir({ options, now: at, cwd }) const extra = [] if (ctx.baselineDiff !== null) { extra.push({ name: 'baseline-diff.json', content: `${JSON.stringify(ctx.baselineDiff, null, 2)}\n` }) } const { files } = writeBundle(bundleDir, { report, findings: sorted, crawl: ctx.crawl, baseline: ctx.productionBaseline, extra, alsoWritten: screenshotNames(ctx.browser, bundleDir), }) return { ctx, findings: sorted, diff: ctx.baselineDiff, baseline: ctx.productionBaseline, notCovered, humanItems: sorted.filter((f) => f.id === 'cutover.human-items').flatMap((f) => f.evidence.map((e) => e.value)), summary, bundleDir, files, } } finally { if (typeof browser?.close === 'function') await browser.close() } }skills/post-launch/scripts/lib/plan.mjs5.5 KB6a0911cbd345upstream ↗
// The thirty-day watch, as a table. // // SCHEDULE is spec §6.2 with every window flattened into explicit day numbers, because "days 3–7" // is a deadline and a plan needs a date. The flattening rule is stated once here so it is the same // everywhere: a **daily** window repeats on each of its days (days 1–3, three times); a **window** // with a range is one task, due on the last day of the range, which is the day it stops being // early and starts being late; a **weekly** cadence repeats on the last day of each of its weeks. // // `automated` says whether this repository can do the task at all. It is deliberately conservative: // reading the Search Console Pages report, confirming a conversion event fired, and deciding // whether the old host may be switched off are human work, and marking them automated would let a // script's silence stand in for somebody actually looking. That is also why a recorded run only // ever covers the automated rows below it — see `watchPlan`. // // `command` is written relative to the skill's own directory, which is where the SKILL.md tells an // agent to run these from. `<url>` and `<n>` are placeholders a person substitutes. export const WATCH_DAYS = 30 // How far ahead the plan looks. A month-long list printed every day is a list nobody reads. export const UPCOMING_WINDOW = 7 const WATCH_COMMAND = 'node scripts/post-launch.mjs --mode watch --url <url> --day <n>' const AUDIT_COMMAND = 'node ../launch-audit/scripts/launch-audit.mjs --url <url>' const AUDIT_BROWSER_COMMAND = 'node ../launch-audit/scripts/launch-audit.mjs --url <url> --browser on' const SPOT_CHECK_COMMAND = 'node scripts/post-launch.mjs --mode cutover --url <url> --redirects redirects.csv' const daily = (task, automated, command) => ({ window: 'days 1-3, daily', days: [1, 2, 3], task, automated, command }) const byDay7 = (task, automated, command) => ({ window: 'days 3-7', days: [7], task, automated, command }) const week2 = (task, automated, command) => ({ window: 'week 2', days: [14], task, automated, command }) const weekly = (task, automated, command) => ({ window: 'weeks 2-4, weekly', days: [14, 21, 28], task, automated, command }) const day30 = (task, automated, command) => ({ window: 'day 30', days: [30], task, automated, command }) export const SCHEDULE = [ daily('Re-run the cutover checks', true, WATCH_COMMAND), daily('Search Console: read the Pages report for new coverage errors', false, null), daily('Review the 404 log — server logs, or Search Console Pages, "Not found"', false, null), daily('Re-measure Core Web Vitals in the lab', true, AUDIT_BROWSER_COMMAND), daily('Confirm the site has stayed up since the cutover', false, null), byDay7('Search Console: the sitemap is processed — compare submitted against indexed', false, null), byDay7('Confirm the analytics conversion events are firing, not just pageviews', false, null), byDay7('Redirect spot-check on the top twenty old urls', true, SPOT_CHECK_COMMAND), byDay7('Broken-link re-crawl of the live site', true, AUDIT_COMMAND), week2('Internal-link crawl as a bot would do it: orphans and depth', true, AUDIT_COMMAND), week2('Validate the structured data on production urls', true, AUDIT_COMMAND), week2('Re-check titles, descriptions and OG tags against the baseline', true, AUDIT_COMMAND), week2('Indexation count from the Search Console Pages report — a site: query is unreliable', false, null), weekly('Organic traffic against the pre-launch export', false, null), weekly('Top-twenty keyword positions, if they are tracked', false, null), weekly('Review the 404 log', false, null), weekly('Core Web Vitals field data, once CrUX has twenty-eight days of it', false, null), day30('Retrospective: what regressed, what the redirect map missed, what to add to the pre-launch gate next time', false, null), day30('Old-host decommission decision — only once Search Console shows no crawl of old urls and the redirects have been in place for thirty days', false, null), ] const DUE_RANK = { overdue: 0, today: 1, upcoming: 2 } const commandFor = (row, day) => (row.command === null ? null : row.command.replace('<n>', String(day))) // What is due on `day`, given what has actually been run. // // A task is `overdue` when its day has passed and nothing covered it. Only an **automated** task // can be covered by a recorded run, and a run covers every automated day at or below its own — // re-running the checks on day 8 answers the day-3 question. A human task is never covered by a // script: nothing in `state.runs` is evidence that somebody opened Search Console, so those stay // overdue until a person clears them, which is the honest answer. // // `upcoming` looks seven days ahead and no further. export function watchPlan(today = 1, state = { runs: [] }) { const day = Number(today) const ranAt = (state?.runs ?? []).map((run) => Number(run.day)).filter((n) => Number.isFinite(n)) const out = [] SCHEDULE.forEach((row, order) => { for (const scheduled of row.days) { let due if (scheduled === day) { due = 'today' } else if (scheduled < day) { if (row.automated && ranAt.some((run) => run >= scheduled)) continue due = 'overdue' } else { if (scheduled > day + UPCOMING_WINDOW) continue due = 'upcoming' } out.push({ due, day: scheduled, task: row.task, automated: row.automated, command: commandFor(row, scheduled), order }) } }) return out .sort((a, b) => DUE_RANK[a.due] - DUE_RANK[b.due] || a.day - b.day || a.order - b.order) .map(({ order, ...entry }) => entry) }skills/post-launch/scripts/lib/watch.mjs6.7 KBae27d719d681upstream ↗
// The watch: one day of the thirty, run. // // A watch day is the cutover checks again, plus the four audit dimensions that catch what drifts in // the weeks after a launch — what the crawl can still reach (`inventory`), what still links to what // (`links`), what the pages say about themselves (`head`), and what search engines are still being // told (`indexability`). The other dimensions are not re-run: images, performance and accessibility // do not change because DNS moved, and running them daily would bury the four that do. // // The bundle directory is **stable** rather than dated — `website-launch/<slug>-post-launch/` — so // thirty days of runs leave one directory with the latest report in it and one `watch.json` // recording every run. A dated directory per day would leave thirty bundles nobody opens and no // single place to look. // // `watch.json` is the operator's own record and the only thing this skill writes outside a report // bundle. It is read before the run (so the plan can say what is overdue) and appended after it. import { join } from 'node:path' import { existsSync } from 'node:fs' import { ensureDir, readJson, writeJson } from '../../../launch-audit/scripts/lib/fs-util.mjs' import { slugify } from '../../../launch-audit/scripts/lib/slug.mjs' import { writeBundle, screenshotNames } from '../../../launch-audit/scripts/lib/report.mjs' import { sortFindings } from '../../../launch-audit/scripts/lib/findings.mjs' import * as inventory from '../../../launch-audit/scripts/lib/checks/inventory.mjs' import * as links from '../../../launch-audit/scripts/lib/checks/links.mjs' import * as head from '../../../launch-audit/scripts/lib/checks/head.mjs' import * as indexability from '../../../launch-audit/scripts/lib/checks/indexability.mjs' import { buildCutoverContext, cutoverNotCovered, cutoverRegistry, renderCutoverReport, renderStartPage, runChecksOver, summaryOf, } from './cutover.mjs' import { watchPlan, WATCH_DAYS } from './plan.mjs' const STATE_FILE = 'watch.json' const DAY_MS = 86_400_000 // The audit dimensions a watch day re-runs, in reading order. export const WATCH_MODULES = [inventory, links, head, indexability] const EMPTY_STATE = () => ({ startedAt: null, url: null, runs: [] }) // The stable directory this watch lives in. Not dated: thirty runs share one bundle and one state // file, and the newest report is always the one at this path. export function watchOutDir({ options, cwd }) { if (options.out) return options.out return join(cwd, 'website-launch', `${slugify(new URL(options.url).hostname)}-post-launch`) } // The state, or an empty watch. A missing file is a watch that has not started; an unreadable one // is treated the same way rather than taking the run down — the day's checks still matter, and the // next run rewrites the file. export function readWatchState(dir) { const path = join(dir, STATE_FILE) if (!existsSync(path)) return EMPTY_STATE() let state try { state = readJson(path) } catch { return EMPTY_STATE() } if (!state || typeof state !== 'object' || !Array.isArray(state.runs)) return EMPTY_STATE() return { startedAt: state.startedAt ?? null, url: state.url ?? null, runs: state.runs } } // Appends one run. `startedAt` is set once, by the first run, and never moved: the watch is thirty // days from the cutover, not thirty days from the last time somebody remembered to run it. export function recordRun(dir, { url, day, at, blockers, high, bundle }) { ensureDir(dir) const previous = readWatchState(dir) const state = { startedAt: previous.startedAt ?? at, url: previous.url ?? url ?? null, runs: [...previous.runs, { day, at, blockers, high, bundle }], } writeJson(join(dir, STATE_FILE), state) return state } // Which day of the watch this is, when nobody said. Day 1 is the day of the cutover run itself, so // a watch started this morning is on day 1 and one started a week ago is on day 8. export function dayFor(state, now) { if (state.startedAt === null) return 1 const started = new Date(state.startedAt).getTime() const at = (now instanceof Date ? now : new Date(now)).getTime() if (!Number.isFinite(started) || !Number.isFinite(at)) return 1 const elapsed = Math.floor((at - started) / DAY_MS) + 1 return Math.min(WATCH_DAYS, Math.max(1, elapsed)) } export async function runWatch({ net, dns, browser = null, options = {}, now = new Date(), cwd = process.cwd() } = {}) { const at = now instanceof Date ? now : new Date(now) const bundleDir = watchOutDir({ options, cwd }) // Read before the run: the plan has to say what was overdue *coming into* today, not what this // run has just covered. const previous = readWatchState(bundleDir) const day = Number.isFinite(Number(options.day)) && options.day !== undefined && options.day !== null ? Math.min(WATCH_DAYS, Math.max(1, Number(options.day))) : dayFor(previous, at) try { const ctx = await buildCutoverContext({ net, dns, options, now: at }) const { tier, lines: browserLines } = await renderStartPage(ctx, browser) ctx.browser = tier const registry = options.registry ?? cutoverRegistry(WATCH_MODULES) const { findings, lines, skipped } = await runChecksOver(ctx, registry) const sorted = sortFindings(findings) const notCovered = cutoverNotCovered(ctx, { tier, skipped, ownLines: lines, browserLines, registry }) const summary = summaryOf(sorted, ctx.crawl.pages.length) const plan = watchPlan(day, previous) const report = renderCutoverReport({ target: ctx.target.url, generatedAt: at.toISOString(), findings: sorted, notCovered, summary, plan, }) const extra = [{ name: 'plan.json', content: `${JSON.stringify({ day, of: WATCH_DAYS, plan }, null, 2)}\n` }] if (ctx.baselineDiff !== null) { extra.push({ name: 'baseline-diff.json', content: `${JSON.stringify(ctx.baselineDiff, null, 2)}\n` }) } const { files } = writeBundle(bundleDir, { report, findings: sorted, crawl: ctx.crawl, baseline: ctx.productionBaseline, extra, alsoWritten: screenshotNames(ctx.browser, bundleDir), }) // Written after the bundle, and deliberately not part of it: watch.json outlives any one run, // so it must never be a file the next run's manifest can clean away. const state = recordRun(bundleDir, { url: ctx.target.url, day, at: at.toISOString(), blockers: summary.blockers, high: summary.high, bundle: bundleDir, }) return { ctx, day, plan, state, findings: sorted, diff: ctx.baselineDiff, baseline: ctx.productionBaseline, notCovered, summary, bundleDir, files, } } finally { if (typeof browser?.close === 'function') await browser.close() } }skills/post-launch/scripts/post-launch.mjs15 KBefb0318b72ecupstream ↗
#!/usr/bin/env node // post-launch — verify the cutover the moment DNS moves, then watch the site for thirty days. // // Two modes, one script, and the same posture as the auditor: it reads, it reports, it exits 0. // Nothing here edits a site, submits a form, flips a setting or touches DNS. The only thing it // writes outside its own report bundle is `watch.json`, the operator's record of which days of the // watch were actually run. // // --mode cutover the twelve questions of cutover day, in the order the day depends on them // --mode watch one day of the thirty: the cutover checks again, the four audit dimensions // that drift after a launch, and what is due today // // The two rules this file enforces that the libraries cannot: // // - **Blockers are read first and announced last.** The terminal summary leads with the bolded // blockers, whatever order the day puts them in, and the human items end with "announce only // after this report is clean". A launch announced over a live noindex cannot be un-announced. // - **"Not covered" ships verbatim.** Every line, every run, including the two things no crawl can // ever see. A cutover report that implies it checked the baseline it was never given is worse // than no report. // // Exit codes: 0 always, however bad the cutover is — this reports, it does not gate. 2 for a usage // error, for `--browser on` with no browser, and for an unexpected failure such as a bad `--out`. import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { defineArgs } from '../../launch-audit/scripts/lib/args.mjs' import { createNet } from '../../launch-audit/scripts/lib/net.mjs' import { createDns } from '../../launch-audit/scripts/lib/dns.mjs' import { createBrowser } from '../../launch-audit/scripts/lib/browser.mjs' import { recordedNetFromPath, offlineDnsResolvers } from '../../launch-audit/scripts/lib/recorded-net.mjs' import { SEVERITIES } from '../../launch-audit/scripts/lib/findings.mjs' import { cutoverOutDir, parseExpectDns, runCutover, messageOf } from './lib/cutover.mjs' import { HUMAN_ITEMS } from './lib/checks/cutover.mjs' import { runWatch, watchOutDir } from './lib/watch.mjs' import { WATCH_DAYS } from './lib/plan.mjs' const MODES = ['cutover', 'watch'] const BROWSER_MODES = ['auto', 'on', 'off'] const WORST_LIMIT = 10 export const args = defineArgs({ name: 'post-launch', description: 'Verify a cutover the moment DNS moves, diff production against the pre-launch baseline, and run the thirty-day watch. Read-only: it never edits a site, submits a form or touches DNS.', flags: { mode: { type: 'string', required: true, help: `cutover (the day itself) or watch (one day of the ${WATCH_DAYS})` }, url: { type: 'string', required: true, help: 'the production url, now that DNS has moved' }, baseline: { type: 'string', help: 'baseline.json from pre-launch or from a staging launch-audit run' }, 'old-url': { type: 'string', help: 'the site being replaced, to confirm it now redirects' }, redirects: { type: 'string', help: "redirects.csv from the audit, re-requested row by row" }, 'expect-analytics': { type: 'string', help: 'the measurement id that must be present (used when there is no baseline)' }, 'staging-url': { type: 'string', help: 'the staging copy, to confirm it is now sealed' }, 'expect-dns': { type: 'string', help: 'the records DNS should now answer, as apex=1.2.3.4,www=cname.host' }, day: { type: 'number', help: `which day of the ${WATCH_DAYS}-day watch this is (watch mode; default: counted from the first run)` }, 'dev-host': { type: 'list', help: 'a staging or dev hostname that must not appear on the live site (repeatable)' }, browser: { type: 'string', default: 'auto', help: 'browser tier: auto, on (fail if unavailable) or off' }, out: { type: 'string', help: 'write the bundle here instead of ./website-launch/<slug>-post-launch[-<date>]' }, }, }) const EXTRA_HELP = [ '', 'Modes:', ' --mode cutover run this the moment DNS has moved. It checks DNS propagation, the', ' certificate, http and www redirects, that the noindex is gone, robots and', ' sitemap on the live host, every row of redirects.csv, the baseline diff,', ' analytics, form endpoints, the staging copy, the old host and the security', ' headers — then prints the six things a person still has to do.', ` --mode watch run this once a day for ${WATCH_DAYS} days. It prints what is due today and`, ' re-runs the automated part.', '', 'Output: a bundle directory holding report.md, findings.json, crawl.json, baseline.json,', 'manifest.json, baseline-diff.json (with --baseline), and in watch mode plan.json and watch.json.', 'Cutover mode writes a dated directory; the watch writes one stable directory it reuses.', '', 'Exit codes: 0 always, however bad the cutover is — this reports, it does not gate.', ' 2 for a usage error, for --browser on with no browser available, and for an', ' unexpected failure (a bad --out path, say).', '', "Testing hooks, used by this repo's tests; a normal run sets none of them:", ' WEBSITE_LAUNCH_RECORDED_FIXTURE=<path to a recorded-responses JSON file>', ' run against those recordings instead of the network.', ' WEBSITE_LAUNCH_RESOLVERS_FIXTURE=<path to a JSON file of resolver answers>', ' answer the public-resolver sample from that file instead of asking 8.8.8.8,', ' 1.1.1.1, 9.9.9.9 and 208.67.222.222. Shape: {"<resolver ip>": {"<host>":', ' {"a": [], "aaaa": [], "cname": null}}}.', ' WEBSITE_LAUNCH_BROWSER_LOADER=fail', " make the browser tier's playwright loader throw, as a machine without it would.", ' WEBSITE_LAUNCH_DEBUG=1', ' print the stack trace of an unexpected failure, not only its message.', ].join('\n') // ---------------------------------------------------------------------------- the testing hooks // A `makeResolver` that answers from a file instead of from the network, so the propagation sample // can be exercised without asking four public resolvers about a host that does not exist. function fixtureMakeResolver(path) { const table = JSON.parse(readFileSync(path, 'utf8')) return async (ip) => { const perHost = table[ip] if (perHost === undefined) { const error = new Error(`${ip} did not answer (recorded run)`) error.code = 'ECONNREFUSED' throw error } const answerFor = (host) => perHost[host] ?? {} return { resolve4: async (host) => answerFor(host).a ?? [], resolve6: async (host) => answerFor(host).aaaa ?? [], resolveCname: async (host) => { const cname = answerFor(host).cname return cname ? [cname] : [] }, } } } // A resolver that refuses, for a recorded run that was given no resolver fixture: a recording has // no DNS in it, and reaching the real network from a test would be worse than saying so. const offlineMakeResolver = async (ip) => { const error = new Error(`${ip} was not asked (recorded run with no resolver fixture)`) error.code = 'ECONNREFUSED' throw error } function adapters() { const fixture = process.env.WEBSITE_LAUNCH_RECORDED_FIXTURE const resolversFixture = process.env.WEBSITE_LAUNCH_RESOLVERS_FIXTURE const net = fixture ? recordedNetFromPath(fixture) : createNet() let dns if (resolversFixture) { dns = createDns({ ...(fixture ? offlineDnsResolvers() : {}), makeResolver: fixtureMakeResolver(resolversFixture) }) } else if (fixture) { dns = createDns({ ...offlineDnsResolvers(), makeResolver: offlineMakeResolver }) } else { dns = createDns() } return { net, dns } } // ------------------------------------------------------------------------------ the terminal const severityLine = (summary) => SEVERITIES .map((severity) => `${severity} ${summary[severity === 'blocker' ? 'blockers' : severity]}`) .join(' ') // Blockers first, bolded, whatever order the day put them in; then the highs. This is the one // ordering that never follows the report's day order, because a blocker found at step six still // has to be the first thing anybody reads. function worstFirst(findings) { const out = [] const blockers = findings.filter((f) => f.severity === 'blocker') for (const finding of blockers) out.push(`**BLOCKER** ${finding.title} — ${finding.page ?? 'site-wide'}`) const high = findings.filter((f) => f.severity === 'high') for (const finding of high.slice(0, WORST_LIMIT)) out.push(`[high] ${finding.title} — ${finding.page ?? 'site-wide'}`) if (high.length > WORST_LIMIT) out.push(`[high] ... and ${high.length - WORST_LIMIT} more, in report.md`) return out } function printPlan(day, plan) { const out = [`Day ${day} of ${WATCH_DAYS}`, ''] const section = (label, due) => { const entries = plan.filter((entry) => entry.due === due) if (entries.length === 0) return out.push(`${label}:`) for (const entry of entries) { out.push(` - day ${entry.day}: ${entry.task}${entry.automated ? '' : ' [human]'}`) if (entry.automated && entry.command !== null) out.push(` ${entry.command}`) } out.push('') } section('Overdue', 'overdue') section('Due today', 'today') section('Coming up', 'upcoming') process.stdout.write(`${out.join('\n')}\n`) } function printSummary(result, { mode }) { const out = [] out.push(`Bundle: ${result.bundleDir}`) out.push(`Pages crawled: ${result.summary.pages}`) out.push(severityLine(result.summary)) const worst = worstFirst(result.findings) if (worst.length > 0) { out.push('') out.push('Read these first:') for (const line of worst) out.push(` ${line}`) } out.push('') // On cutover day these gate the announcement. On a watch day the announcement has happened and // they are standing rules — most of all the one about keeping the old host up. out.push(mode === 'cutover' ? 'Before you announce:' : 'Standing human items:') for (const item of HUMAN_ITEMS) out.push(` - ${item}`) out.push('') out.push('Not covered:') for (const line of result.notCovered) out.push(` - ${line}`) out.push('') out.push(result.summary.blockers > 0 ? 'There is a blocker. Fix it, re-run this, and do not announce the launch until the report is clean.' : mode === 'cutover' ? 'Read report.md end to end, work the human items, then announce. Repeat Not covered verbatim to whoever asked.' : 'Read report.md, then work whatever is still overdue above. Repeat Not covered verbatim.') process.stdout.write(`${out.join('\n')}\n`) } // ------------------------------------------------------------------------------------- the CLI function usageError(errors, help) { process.stderr.write(`${errors.map((e) => `error: ${e}`).join('\n')}\n\n${help}\n${EXTRA_HELP}\n`) process.exitCode = 2 } async function runCli(argv, { cwd, now }) { const { values, errors, help } = args.parse(argv) if (values.help) { process.stdout.write(`${help}\n${EXTRA_HELP}\n`) process.exitCode = 0 return } if (values.mode !== undefined && !MODES.includes(values.mode)) { errors.push(`--mode must be one of ${MODES.join(', ')}`) } if (!BROWSER_MODES.includes(values.browser)) { errors.push(`--browser must be one of ${BROWSER_MODES.join(', ')}`) } if (values.url !== undefined) { try { const parsed = new URL(values.url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') errors.push('--url must be http or https') } catch { errors.push(`--url is not a url: ${values.url}`) } } if (values.day !== undefined) { if (!Number.isInteger(values.day) || values.day < 1 || values.day > WATCH_DAYS) { errors.push(`--day must be a whole number from 1 to ${WATCH_DAYS}`) } // Cutover day is one day and it is today. Accepting --day there would look like it scoped the // run to a day of the watch, and it would silently do nothing at all. if (values.mode === 'cutover') errors.push('--day applies to --mode watch') } let expectDns = null try { expectDns = parseExpectDns(values['expect-dns']) } catch (error) { errors.push(messageOf(error)) } if (errors.length > 0) { usageError(errors, help) return } const options = { url: values.url, baseline: values.baseline, oldUrl: values['old-url'], redirectsCsv: values.redirects, expectAnalytics: values['expect-analytics'], stagingUrl: values['staging-url'], expectDns, devHosts: values['dev-host'], day: values.day, out: values.out, } const { net, dns } = adapters() // The browser writes its screenshots inside the bundle, so the bundle path is settled first. // Both resolvers are pure over these inputs, so this is the directory the run will write to. const outDir = values.mode === 'watch' ? watchOutDir({ options, cwd }) : cutoverOutDir({ options, now, cwd }) const browser = createBrowser({ mode: values.browser, outDir, ...(process.env.WEBSITE_LAUNCH_BROWSER_LOADER === 'fail' ? { loader: () => { throw new Error('playwright not installed (test hook)') } } : {}), }) // `--browser on` is a person saying "I want the rendered tier". Quietly falling back to HTTP // would hand them a report that looks complete and is not. if (values.browser === 'on' && (await browser.open()) !== true) { process.stderr.write(`error: --browser on, but the browser tier could not start: ${browser.reason}\n`) process.exitCode = 2 return } const result = values.mode === 'watch' ? await runWatch({ net, dns, browser, options, now, cwd }) : await runCutover({ net, dns, browser, options, now, cwd }) if (values.json) { process.stdout.write(`${JSON.stringify({ bundle: result.bundleDir, files: result.files, summary: result.summary, notCovered: result.notCovered, ...(values.mode === 'watch' ? { plan: result.plan } : {}), }, null, 2)}\n`) } else { // The due list comes first on a watch day: it is the reason somebody ran the command. if (values.mode === 'watch') printPlan(result.day, result.plan) printSummary(result, { mode: values.mode }) } process.exitCode = 0 } // A stack trace is not a report, so the boundary turns an unexpected failure into the same shape a // usage error has: one line on stderr and exit 2. export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), now = new Date() } = {}) { try { await runCli(argv, { cwd, now }) } catch (error) { process.stderr.write(`error: ${messageOf(error)}\n`) if (process.env.WEBSITE_LAUNCH_DEBUG === '1' && error?.stack) process.stderr.write(`${error.stack}\n`) process.exitCode = 2 } } if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { await main() }skills/post-launch/references/baseline-diff.md5.3 KBa796a80c4965upstream ↗
# The baseline diff `baseline.json` is a snapshot of a site: one record per page plus a handful of site-level facts. `pre-launch` and `launch-audit` both write one. `post-launch` crawls production, builds the same shape, and compares. The comparison is only as good as the baseline. A baseline taken against staging is the right comparison for "did the deploy survive the cutover". A baseline taken against the *old* site is the right comparison for "did the rebuild keep what the old site had" — and it is a different question with a much noisier answer, because a rebuild changes titles on purpose. **Say which one you used.** A diff full of changed titles is alarming against staging and expected against a predecessor. --- ## What a baseline records Per page, keyed by url: | Field | What it is | Moves at a launch? | |---|---|---| | `status` | the HTTP status the crawl got | no — a change here is a finding | | `finalUrl` | where the request landed | no | | `redirectChain` | the hops it took | no — a new chain is `baseline-new-redirect` | | `title` | the `<title>` | sometimes, on purpose | | `description` | the meta description | sometimes, on purpose | | `canonical` | the canonical url | **almost never** | | `robotsMeta` | the robots meta content | **never, except deliberately** | | `h1` | the first h1 | sometimes | | `jsonldTypes` | the schema types on the page | rarely | | `analyticsTags` | which analytics tools were detected | **never** | | `headers` | content-type, cache-control, x-robots-tag | rarely | | `imageCount`, `linkCount` | counts, for context | freely | Site level: `host`, `https`, `hsts`, `cert.expires`, `robotsTxtHash`, `sitemapUrls`, `securityHeaders`. --- ## The four findings, and how to read each ### `cutover.baseline-missing-page` (high) A url the baseline recorded that a crawl of production no longer reaches. Two causes, and they need different fixes: - **The page is gone.** Restore it, or redirect its url to whatever replaced it. A 404 on a url that existed last week loses every link to it. - **The page is there and nothing links to it any more.** The crawl follows links and the sitemap; a page in neither is a page search engines will also stop reaching. Link it, or list it. Check the crawl caps in **Not covered** before you act: a page dropped by `max-pages` is missing from the crawl, not from the site. ### `cutover.baseline-changed` (medium) One finding per field that moved, with before and after. Triage by field: - **`canonical` changed** — take this seriously. A canonical that moved, or became relative, or now points at another page, hands that page's ranking somewhere else. Almost never intentional. - **`robotsMeta` appeared** — a `noindex` on a page that did not have one is the site-wide blocker's quieter sibling. Was it intended for the thank-you page and applied to the layout? - **`title` / `description` changed** — expected on a rebuild, unexpected on a deploy. What to look for is not the change but the *shape* of it: every page now carrying the same title, or a title that reverted to the framework default, or a description that is empty. - **`h1` changed** — usually copy. Worth a glance, rarely worth a fix. - **`jsonldTypes` changed** — a lost `Organization` or `Product` type means a rich result will stop appearing. A gained one is usually fine. ### `cutover.baseline-new-redirect` (medium) A url that answered directly before and takes a hop now. Fine as a decision, expensive as an accident: every internal link, sitemap entry and search result pointing at the old url now pays a round trip, and the moment a second rule lands on the same path you have a chain. If the move was intended, update the internal links and the sitemap so nothing depends on the redirect. The redirect stays — for external links — but the site should not need it. ### `cutover.analytics-missing` (high) A page that carried a tag in the baseline and carries none now. Nearly always one layout that lost the snippet, which means it is one fix affecting many pages — group it that way when you report. With no baseline, this falls back to `--expect-analytics <id>`: it looks for that exact measurement id in the source of the start page. That is a weaker check and the report says so. --- ## Site-level: `securityHeaders` `cutover.security-headers-regressed` (medium) fires per header that the baseline had and production does not. The usual cause is that the header was never set by the application: it came from the old host's config, or from a proxy in front of the old site, and the new platform has its own defaults. Compare the whole set rather than the one header named. If one moved, the rest of the configuration probably did not come across either. --- ## What the diff cannot tell you - **Whether a change was intended.** It reports movement; a person decides. Never describe a diff entry as damage without saying which. - **Anything about a page the baseline never saw.** New pages appear in `newPages` and are not findings — the baseline had nothing to say about them. - **Anything about a page the crawl did not reach this time.** Read the caps in **Not covered** before concluding a page is gone. - **Copy.** Two different titles are two strings. Whether the new one is *right* needs the signed-off copy and a person reading both.skills/post-launch/references/cutover-day.md8.9 KB012a4c3ef092upstream ↗
# Cutover day, in order The checks run in the order of the day, not in order of severity. That is deliberate: on cutover day the questions depend on each other, and answering one out of order gives you a confident wrong answer. If DNS has not finished moving, every other check describes whichever host happened to answer — possibly the old one — and you will spend an hour fixing a site nobody is being sent to. Read the report top to bottom once, in this order, before you fix anything. Then fix in severity order. --- ## 1. DNS — has it actually moved, everywhere? **`cutover.dns-unexpected`** (high) · **`cutover.dns-propagation`** (info, high while they disagree) The run asks four public resolvers — Google, Cloudflare, Quad9, OpenDNS — separately, rather than asking the machine it happens to be running on. Your own resolver is the least representative answer available: it may be your registrar's, your VPN's, or a cache you warmed yesterday. **Why first.** Everything below is a statement about whatever host answered. Until the resolvers agree, the rest of the report is provisional and should be read that way. **What to do while they disagree.** Nothing but wait, and keep the old host serving. Propagation is bounded by the TTL that was in place *before* the change — which is why `pre-launch` asks for the TTL to be lowered 24 hours ahead. Re-run when they agree. **What "unexpected" means.** A resolver whose answer does not *contain* the record you named in `--expect-dns`. Containment, not equality: a host on a CNAME answers with the CNAME and the A records behind it, so a correct `www=cname.host` has more in it than you asked for. On a launch that moved to a new provider an unexpected answer is normal for an hour and alarming after a day. **A resolver that could not be reached is neither.** It knows nothing, which is not the same as answering wrongly, so it is excluded from both the expectation check and the agreement check and named in **Not covered** with the count instead. A firewalled resolver must never read as a failed cutover. --- ## 2. Transport — can anybody actually reach it? **`cutover.https-cert-host`** (blocker) · **`cutover.http-redirect`** (high) · **`cutover.canonical-host`** (high) **Why second.** A certificate that does not cover the host is a full-page browser warning, which no ordinary visitor clicks through. It does not matter what the page says if nobody gets to it. This is the most common launch-day failure on a managed host: the domain was added, the certificate was issued for the previous name, and the dashboard says "deployed". `http` must answer 301 or 308 to `https`, in one hop. `www` and the apex must be one canonical host, with the other redirecting to it — and the certificate has to cover both, or the redirect itself throws a warning. --- ## 3. The noindex — is the site allowed to exist? **`cutover.noindex-live`** (blocker) The staging noindex reaching production is the single most expensive launch mistake there is, because it is invisible: the site looks perfect, everybody celebrates, and three weeks later somebody asks why there is no traffic. It hides in three places and the check looks at all three — the meta tag, the `X-Robots-Tag` response header, and a blanket `Disallow: /` in robots.txt. **Why third.** It is a blocker, and it is the one blocker that costs more the longer it stands: the crawl budget spent on those weeks does not come back. Fix it, redeploy, confirm it is gone, then request indexing for the top pages. --- ## 4. Robots and sitemap on the live host **`cutover.robots-live`** (high) · **`cutover.sitemap-live`** (high) · **`cutover.sitemap-references-staging`** (high) A sitemap is a request to index exactly the urls in it. A generator configured with the staging site url writes a sitemap full of staging urls, and a live host that serves it is asking search engines to index the wrong copy of the site. **Why here.** It follows the noindex because both are the same conversation with a crawler, and it precedes the redirects because the sitemap is how the new urls get discovered at all. Submit the sitemap only once this section is clean — submitting a staging sitemap is hard to take back. --- ## 5. Redirects — does every old url still land? **`cutover.redirect-drift`** (high) · **`cutover.redirect-unverified`** (medium) Every row of `redirects.csv` is re-requested **at the url it was written with**, not rewritten onto the production origin. That is the real question: somebody has that url in a bookmark, a search result or another site's link, and after DNS moved it resolves somewhere new. What happens? A row passes when it answers a single permanent hop — 301 or 308 — to the destination the audit recorded. It fails on a 302 or 307 (temporary: search engines keep the old url), on a chain (each hop is a round trip and crawlers give up), on landing somewhere else, and on rows the audit flagged before the launch that are still 404ing. A row that answered **nothing at all** is a separate, quieter finding: `redirect-unverified`. It has not been shown to have drifted and it has not been shown to be right. Mid-cutover the usual cause is the old host's own DNS still settling; after that, it is a host somebody already switched off, which is its own problem — the redirect map only works while something is still answering on it. **Why here.** It needs DNS to have moved and it needs the site to be reachable, so it cannot come earlier. It comes before the baseline diff because a redirect that drifted usually explains several of the diff's findings. --- ## 6. The baseline diff **`cutover.baseline-missing-page`** (high) · **`cutover.baseline-changed`** (medium) · **`cutover.baseline-new-redirect`** (medium) Production, crawled now, against the site as it was recorded before the launch. See `baseline-diff.md` for which fields are expected to move and which never are. **Why here.** Everything above it can cause a diff finding, so reading the diff first would send you chasing symptoms. A page "missing" because a redirect drifted is one fix, not two. --- ## 7. Analytics **`cutover.analytics-missing`** (high) · **`cutover.analytics-not-firing`** (high, browser tier) A launch that measures nothing cannot be reported on, and no analytics product backfills — the gap is permanent. The first check compares against the baseline (or, with no baseline, looks for the measurement id you named). The second needs the optional browser tier: it renders the page and watches for a request to the tool's collector, which is the only way to tell an installed tag from a working one. **Why here.** It is not a blocker — the site works — but every hour it stays broken is an hour of launch-week data nobody will ever have. --- ## 8. Forms **`cutover.form-endpoint-down`** (high) Every same-origin form action, and every third-party form embed, asked once with a GET. Nothing is ever submitted. A 405 is a healthy answer from a POST-only endpoint; a 500, a 404 or no response at all is a form that renders perfectly and silently loses every enquiry. **This check is not enough on its own.** A 200 from an endpoint is not a delivered message. The human item stands: somebody submits the form and the person who owns the inbox confirms it arrived. --- ## 9. The staging copy **`cutover.staging-still-indexable`** (high) A staging host answering 200, with no noindex, no robots block and no redirect, is a complete second copy of the new site. Search engines choosing between two identical sites do not always choose the one you launched, and the wrong one has no analytics, no conversions and a hostname nobody can put on a business card. Best to worst: take it down · authenticate it · return 410 · redirect it to production · noindex it. The last one still serves the content to anything that ignores the tag. --- ## 10. The old host **`cutover.old-host-serving-content`** (high) If the old site still answers with its own pages, two sites are live with the same content and the same purpose. Rankings split, old links keep people on the old one, and any form on it delivers somewhere nobody is watching any more. The old host must stay **alive and redirecting**, not switched off — see `thirty-day-watch.md` for when it may finally go. --- ## 11. Security headers **`cutover.security-headers-regressed`** (medium) Header configuration rarely moves with the content. A rule that lived in the old host's config, or a proxy that sat in front of the old site, does not exist on the new platform. This compares against the baseline, so it only fires on something that used to be there. --- ## 12. The six things a person does **`cutover.human-items`** (info) Submit the sitemap · request indexing · confirm the Search Console property · verify analytics in real time · keep the old host up · announce only after this report is clean. **Why last.** Because the announcement is last. Everything above exists so that the final item can be taken honestly, and an announcement made over a standing blocker cannot be withdrawn.skills/post-launch/references/reading-the-report.md5.7 KBf5b50b6be303upstream ↗
# Reading the report ## The bundle Cutover mode writes a dated directory; the watch reuses one stable directory for all thirty days, so the newest report is always at the same path. website-launch/<slug>-post-launch-<date>/ cutover website-launch/<slug>-post-launch/ watch | File | What it holds | |---|---| | `report.md` | the report a person reads: cutover verification in day order, then Not covered | | `findings.json` | every finding, machine-readable, with its evidence | | `crawl.json` | what the crawl reached: url, status, final url, depth, source | | `baseline.json` | a fresh baseline of production, ready to be the next comparison | | `baseline-diff.json` | the diff, when a baseline was given | | `plan.json` | what was due, in watch mode | | `watch.json` | the record of every watch run: day, time, blockers, high, bundle | | `manifest.json` | what this run wrote, so a re-run can clear what it no longer produces | `watch.json` is deliberately not part of the manifest: it outlives any one run and must never be cleaned away by the next one. ## Severities | Severity | What it means here | When to act | |---|---|---| | `blocker` | the launch has not really happened — nobody can reach the site, or search engines are told to forget it | before anything else, and before any announcement | | `high` | something is losing traffic, enquiries or rankings right now | today | | `medium` | something changed that somebody should explain | this week | | `low` | worth knowing | when convenient | | `info` | a listing, not a failure — the resolver answers, the human items | read it | There are exactly two blockers: `cutover.https-cert-host` and `cutover.noindex-live`. ## Two orderings, on purpose **`report.md` is in day order.** DNS, transport, the noindex, robots and sitemap, redirects, the baseline diff, analytics, forms, staging, the old host, headers, people. That is the order the day depends on itself, and reading the report in it stops you fixing a symptom of something further up. A blocker is bolded `**BLOCKER**` wherever it falls, so it stays visible while you are reading step six. **The terminal summary is in severity order.** Blockers first, bolded, then the highs. That is the order somebody acts in. If the two seem to disagree, they do not: one is how to understand the day, the other is what to do first. ## The check ids | Step | Ids | |---|---| | 1. DNS | `cutover.dns-unexpected` · `cutover.dns-propagation` | | 2. Transport | `cutover.https-cert-host` · `cutover.http-redirect` · `cutover.canonical-host` | | 3. Noindex | `cutover.noindex-live` | | 4. Robots & sitemap | `cutover.robots-live` · `cutover.sitemap-live` · `cutover.sitemap-references-staging` | | 5. Redirects | `cutover.redirect-drift` · `cutover.redirect-unverified` | | 6. Baseline | `cutover.baseline-missing-page` · `cutover.baseline-changed` · `cutover.baseline-new-redirect` | | 7. Analytics | `cutover.analytics-missing` · `cutover.analytics-not-firing` | | 8. Forms | `cutover.form-endpoint-down` | | 9. Staging | `cutover.staging-still-indexable` | | 10. Old host | `cutover.old-host-serving-content` | | 11. Headers | `cutover.security-headers-regressed` | | 12. People | `cutover.human-items` | A watch day adds the `inventory`, `links`, `head` and `indexability` dimensions from `launch-audit`, in their own sections below the cutover one. The other audit dimensions are not re-run: images, performance and accessibility do not change because DNS moved, and running them daily would bury the four that do. ## Not covered Every run ends with it, and it always has content, because two of its lines are unconditional. Read it before you conclude anything, and repeat it to whoever asked for the report **verbatim** — every line, unedited, however long. What lands there: - **Inputs you did not give.** No `--baseline`, so nothing was compared. No `--redirects`, so no old url was re-requested. No `--staging-url`, so nothing confirmed the staging copy is sealed. No `--old-url`, no `--expect-dns`. Each is a named line, not a silence. - **Rows the redirect map could not promise anything about** — the ones the audit recorded as `error` or `unknown`. (A row that *was* verifiable and simply did not answer is a finding instead, `cutover.redirect-unverified`.) - **Public resolvers that could not be reached**, with how many of the four and which. - **Caps.** A probe set that stopped at its limit, with the count and the cap. - **The browser tier**, when Playwright is not installed, which is what makes `cutover.analytics-not-firing` unanswerable. - **A TLS certificate that was never read**, which is what makes `cutover.https-cert-host` unanswerable. A certificate nobody looked at must never read like one that checked out. - **Crashes.** A check that threw costs that check and says so by name. A finding that failed validation is dropped and says so. A report that is quietly short one check is worse than one that admits the gap. - **The two things no crawl can ever see**, always last: copy against the signed-off documents, and pages against the agreed scope. A skip is not a pass. `Not covered` is not a pass. ## Triage 1. **Read the whole report before fixing anything.** Thirteen findings from one drifted redirect is one fix, and you cannot see that one finding at a time. 2. **Fix causes, not symptoms.** DNS before transport, transport before content, redirects before the baseline diff. 3. **Re-run after each fix.** The run is cheap and the report is the only evidence. 4. **Do not announce until the blockers are gone.** Then work the six human items, and announce after them. 5. **Start the watch the same day.** A launch that is clean at 4pm can break at the next deploy.skills/post-launch/references/thirty-day-watch.md5.6 KBac87afed07c5upstream ↗
# The thirty-day watch A launch is not an event, it is a month. Most of what goes wrong at a cutover is invisible on the day and obvious in week three, by which time nobody is looking. This is the schedule, what to read where, and — the part that matters most — what normal looks like, so an ordinary post-launch dip is not reported as a catastrophe. Run it with: node scripts/post-launch.mjs --mode watch --url <production url> --day <n> It prints what is overdue, what is due today, and what is coming in the next seven days, then runs the automated part and records the run in `watch.json`. **What a recorded run covers.** An automated task on an earlier day: re-running the checks on day 8 answers the day-3 question. It never covers a human task. Nothing a script does is evidence that somebody opened Search Console, so those stay on the overdue list until a person clears them. That is the honest answer and it is meant to be slightly annoying. --- ## Days 1–3, every day | Task | Automated | |---|---| | Re-run the cutover checks | yes | | Search Console: the Pages report, for new coverage errors | no | | Review the 404 log — server logs, or Search Console Pages, "Not found" | no | | Re-measure Core Web Vitals in the lab | yes | | Confirm the site has stayed up since the cutover | no | **What to look at.** In Search Console, the **Pages** report (not the old "Coverage" name): the "Not indexed" reasons are where a launch problem surfaces first. "Excluded by noindex" on day two means the blocker came back with a deploy. "Not found (404)" climbing means a redirect is missing. **What healthy looks like.** Crawling *increases* sharply — a changed site invites a re-crawl. Indexed count barely moves, because indexing lags crawling by days. Impressions dip. A 10–25% drop in organic traffic in the first fortnight of a domain or url migration is ordinary and recovers; it is not a reason to roll anything back on day two. **What is not healthy.** Indexed pages falling while 404s rise — that is redirect coverage, not lag. Any new "Excluded by noindex" or "Blocked by robots.txt" on urls that should be indexed. --- ## Days 3–7 | Task | Automated | |---|---| | Search Console: the sitemap is processed — submitted against indexed | no | | Confirm the analytics conversion events are firing, not just pageviews | no | | Redirect spot-check on the top twenty old urls | yes | | Broken-link re-crawl of the live site | yes | **The sitemap.** "Submitted 120, indexed 8" on day four is normal. On day twenty it is a problem. What matters here is that the sitemap was *read* — a "Couldn't fetch" is a same-day fix. **Conversion events.** Pageviews firing does not mean conversions are. A form that posts to a new endpoint, or a thank-you page at a new url, breaks the event without breaking the tag. Submit the form yourself and watch the event arrive. **The top twenty old urls.** Not a sample of all of them — the twenty that earned the most traffic before the launch. Take them from the pre-launch Search Console export. If the export was never taken, say so plainly: it cannot be taken now, and this is the check that suffers. --- ## Week 2 | Task | Automated | |---|---| | Internal-link crawl as a bot would do it: orphans and depth | yes | | Validate the structured data on production urls | yes | | Re-check titles, descriptions and OG tags against the baseline | yes | | Indexation count from the Search Console Pages report | no | **Orphans and depth.** A rebuild moves navigation around. A page that is in the sitemap and linked from nothing is a page search engines will quietly stop trusting. Anything a visitor should reach in three clicks should be reachable in three links. **Indexation count.** Use the Pages report. A `site:` query is not a measurement — it is a rough, cached, personalised estimate, and people have made bad decisions from watching it move. --- ## Weeks 2–4, once a week | Task | Automated | |---|---| | Organic traffic against the pre-launch export | no | | Top-twenty keyword positions, if they are tracked | no | | Review the 404 log | no | | Core Web Vitals field data, once CrUX has twenty-eight days of it | no | **Field data.** A new domain has no field data. CrUX needs 28 days of real traffic before Search Console's Core Web Vitals report says anything, and a site below the traffic threshold never gets it at all. Until then, lab numbers are the only measurement available and they are not the same thing. Saying "the field data is empty because the domain is new" is the correct answer, not a gap. **Traffic.** Compare against the pre-launch export, same weekday, same window. Week-on-week against launch week compares against an anomaly. --- ## Day 30 **The retrospective.** Three questions, written down where the next launch will find them: 1. What regressed that the gate should have caught? Add a check to `pre-launch`. 2. What did the redirect map miss? Usually a whole template nobody listed — tags, pagination, author pages, a legacy path pattern. 3. What did we learn about this stack that the next launch on it should know? **The old host.** It may be decommissioned only when **both** are true: - Search Console shows no crawl of old urls, and - the redirects have been in place for at least thirty days. Until both hold, keep paying for the hosting and the domain. The redirect map is the only thing carrying the old site's links and rankings across, and it only works while somebody is still answering on that host. Domains are cheap; the links are not replaceable. If the old host has to go earlier for a reason outside your control, say what it costs before it happens rather than after.README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/ethanplusai-astro-conversion-kit-skills-post-launch.md