diff --git a/.github/scripts/verify-docs-deployment.mjs b/.github/scripts/verify-docs-deployment.mjs new file mode 100644 index 000000000..9594a1883 --- /dev/null +++ b/.github/scripts/verify-docs-deployment.mjs @@ -0,0 +1,616 @@ +// Asserts that the GitHub Pages deployment *this* workflow run produced is the +// one the live docs site is actually serving. +// +// Why this exists (issue #1268). A release PR always edits docs/guide/**, which +// matches the `push: branches: [main]` path filter in docs.yml, and the release +// tag then points at that same merge commit. Both runs hand +// `actions/deploy-pages` the same `pages_build_version` — it is `github.sha`, +// and the action exposes no input to override it — so the two deployments +// collide under one identity and one of them is silently stranded. Every job +// goes green, the deployment reports success, and `gh-pages` is byte-correct; +// the only symptom is that the live site keeps showing the previous release. +// +// Nothing else in the pipeline ever looks at the live site, so no amount of +// green CI could catch that. This gate is cause-agnostic: it fails whenever the +// bytes on the live site did not come from this run, whatever stranded them. +// +// Run it locally against a deployed site with: +// DOCS_BASE_URL=https://microsoft.github.io/microsoft-ui-reactor/ \ +// DOCS_EXPECTED_RUN_ID=123 DOCS_EXPECTED_RUN_ATTEMPT=1 \ +// DOCS_EXPECTED_VERSIONS='[{"version":"main","aliases":[]}]' \ +// DOCS_PUBLISHED_VERSIONS='["main"]' \ +// node .github/scripts/verify-docs-deployment.mjs +// +// The decision logic is a pure function so it can be tested without a network: +// see .github/scripts/verify-docs-deployment.test.mjs. + +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +export const STAMP_PATH = "deploy-stamp.json"; +export const VERSIONS_PATH = "versions.json"; +export const ROOT_INDEX_PATH = "index.html"; +export const LATEST_ALIAS = "latest"; + +/** + * Per-request ceiling. Deliberately well under the polling window so a stalled + * connection costs one round rather than the whole budget, and under the job's + * own timeout so the failure is this gate's diagnostic rather than a silent + * runner kill. + */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; + +/** + * Timeout signal for one request, plus the handle to cancel it. + * + * Deliberately not `AbortSignal.timeout()`: that timer is unref'd, so it does + * not hold the event loop open and never fires when nothing else is pending. + * Real traffic hides this because the socket keeps the loop alive, but it made + * the regression suite hang and take the rest of the file down with it. A + * plain `setTimeout` fires reliably, and cancelling it after the request + * settles stops a fast response from leaving a 20s timer behind. + */ +function defaultCreateTimeout(ms) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms); + return { signal: controller.signal, cancel: () => clearTimeout(timer) }; +} + +/** + * The page a version probe fetches. + * + * Explicitly `index.html` rather than the bare directory: a bare directory is + * only equivalent on a server that has no directory listing. GitHub Pages 404s + * a directory whose index is missing, but a plain static server answers 200 + * with a generated listing — which quietly turns a broken deployment into a + * passing probe when this gate is exercised locally. + */ +function versionIndex(version) { + return `${version}/index.html`; +} + +/** + * @typedef {{ ok: boolean, status: number|null, body: string|null, error: string|null }} Probe + * @typedef {{ version: string, title?: string, aliases?: string[] }} VersionEntry + */ + +/** Human-readable shorthand for what a probe actually returned. */ +export function describeProbe(probe) { + if (!probe) return "not attempted"; + if (probe.error) return `request failed: ${probe.error}`; + return `HTTP ${probe.status}`; +} + +function parseJson(text) { + try { + return { value: JSON.parse(text ?? ""), error: null }; + } catch (err) { + return { value: null, error: err instanceof Error ? err.message : String(err) }; + } +} + +function aliasHolder(entries, alias) { + const match = entries.find((entry) => (entry?.aliases ?? []).includes(alias)); + return match ? match.version : null; +} + +/** + * Picks the version directories worth fetching. + * + * `targets` are the versions this run actually published, because those are the + * ones whose bytes are new on the live site. Probing only the `latest` holder + * would miss them: publishing a backported tag deliberately does not move + * `latest` (see the "Publish the release version" step in docs.yml), and a + * `main` push republishes `main` while `latest` sits on a release. In both + * cases a broken new directory would pass as long as `versions.json` listed it. + * The `latest` holder is probed as well, since it is what the site root serves. + * + * `control` is any *other* published version, fetched with an identical request + * shape: it is the positive control that separates "the probe cannot see the + * site at all" from "the site is serving someone else's deployment". A no-match + * is not a measurement until the same probe is shown able to match. + */ +export function selectProbeTargets(expectedVersions, publishedVersions = []) { + const entries = Array.isArray(expectedVersions) ? expectedVersions : []; + const published = Array.isArray(publishedVersions) ? publishedVersions.filter(Boolean) : []; + const latest = aliasHolder(entries, LATEST_ALIAS); + + // Deliberately not intersected with `expectedVersions`. A recorded version + // that the site does not list is a real inconsistency, and dropping it here + // would shrink the probe set on exactly the run that needs it most; evaluate() + // reports it instead. + const targets = []; + for (const version of published) { + if (!targets.includes(version)) targets.push(version); + } + + // Only reached when the caller could not say what it published; keeps the + // gate meaningful rather than probing nothing at all. + if (targets.length === 0) { + const fallback = + latest ?? entries.find((entry) => entry?.version === "main")?.version ?? entries[0]?.version ?? null; + if (fallback) targets.push(fallback); + } + + if (latest && !targets.includes(latest)) targets.push(latest); + + const control = + entries.map((entry) => entry?.version).find((version) => version && !targets.includes(version)) ?? null; + + return { targets, control }; +} + +/** + * The version or alias a mike-generated site root forwards to, or null when the + * document does not look like one. + * + * Parsed rather than substring-matched. `set-default` writes the target into a + * `location.replace(...)` call and a `