Skip to content

Commit 8b583a4

Browse files
authored
Merge pull request #365 from OpenElements/sitemap-check
feat: add sitemap parity check workflow and script
2 parents 3c88e20 + 292e666 commit 8b583a4

2 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
name: Sitemap Parity
2+
3+
# Ensures every URL currently listed in the production sitemap is still
4+
# reachable on the deployment that will be produced by this branch. Runs on:
5+
# - pull_request -> waits for the Netlify deploy-preview, then checks
6+
# - push to main -> waits for the Netlify production deploy, then checks
7+
#
8+
# In both cases the job blocks until Netlify posts a successful commit status
9+
# for the exact commit under test (Netlify integrates via the GitHub Commit
10+
# Status API, not the Deployments API), then runs the check against the URL
11+
# from that status. If any prod URL is unreachable on the new deploy, the
12+
# check fails and blocks the merge.
13+
14+
on:
15+
pull_request:
16+
branches: ['main']
17+
push:
18+
branches: ['main']
19+
20+
permissions:
21+
contents: read
22+
23+
concurrency:
24+
group: sitemap-parity-${{ github.event.pull_request.head.sha || github.sha }}
25+
cancel-in-progress: true
26+
27+
jobs:
28+
check:
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 20
31+
32+
steps:
33+
- name: Checkout repository
34+
uses: actions/checkout@v4
35+
with:
36+
# For PRs, check out the PR head commit so the script matches what
37+
# Netlify actually deployed. For pushes, this is the pushed commit.
38+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
39+
40+
- name: Setup Node.js
41+
uses: actions/setup-node@v4
42+
with:
43+
node-version: '20'
44+
45+
- name: Wait for Netlify deployment
46+
id: wait
47+
env:
48+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
50+
# Total wait budget in seconds (default 15 min) and poll interval.
51+
WAIT_TIMEOUT: '900'
52+
WAIT_INTERVAL: '15'
53+
run: |
54+
set -euo pipefail
55+
echo "Waiting for a Netlify commit status on commit $SHA ..."
56+
57+
# Netlify reports build status via the GitHub Commit Status API
58+
# (context typically `netlify/<site-name>/deploy-preview`) rather
59+
# than the Deployments API, so we poll that endpoint.
60+
deadline=$(( $(date +%s) + WAIT_TIMEOUT ))
61+
preview_url=""
62+
63+
while [ "$(date +%s)" -lt "$deadline" ]; do
64+
status_json=$(gh api "repos/${{ github.repository }}/commits/$SHA/status")
65+
66+
# Grab the first status whose context looks like a Netlify one.
67+
# `map(select(...)) | .[0]` normalises the jq stream into a single
68+
# (possibly null) object so downstream extraction is unambiguous.
69+
netlify_state=$(printf '%s' "$status_json" | jq -r '
70+
.statuses
71+
| map(select(.context | ascii_downcase | startswith("netlify/")))
72+
| .[0].state // ""')
73+
netlify_target=$(printf '%s' "$status_json" | jq -r '
74+
.statuses
75+
| map(select(.context | ascii_downcase | startswith("netlify/")))
76+
| .[0].target_url // ""')
77+
78+
if [ "$netlify_state" = "success" ]; then
79+
if [ -z "$netlify_target" ] || [ "$netlify_target" = "null" ]; then
80+
echo "::error::Netlify status is success but has no target_url."
81+
exit 1
82+
fi
83+
preview_url="$netlify_target"
84+
echo "Found successful Netlify deployment -> $preview_url"
85+
break
86+
elif [ "$netlify_state" = "failure" ] || [ "$netlify_state" = "error" ]; then
87+
echo "::error::Netlify build reported state=$netlify_state for commit $SHA."
88+
exit 1
89+
elif [ -n "$netlify_state" ]; then
90+
echo "Netlify status is currently: $netlify_state. Waiting ${WAIT_INTERVAL}s ..."
91+
else
92+
echo "No Netlify status posted yet. Waiting ${WAIT_INTERVAL}s ..."
93+
fi
94+
95+
sleep "$WAIT_INTERVAL"
96+
done
97+
98+
if [ -z "$preview_url" ]; then
99+
echo "::error::Timed out after ${WAIT_TIMEOUT}s waiting for a Netlify deployment on $SHA."
100+
exit 1
101+
fi
102+
103+
echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT"
104+
105+
- name: Check sitemap parity
106+
env:
107+
PREVIEW_URL: ${{ steps.wait.outputs.preview_url }}
108+
run: |
109+
echo "Preview URL: $PREVIEW_URL"
110+
node scripts/check-sitemap-parity.mjs \
111+
--prod-base=https://open-elements.com \
112+
--preview-base="$PREVIEW_URL" \
113+
--concurrency=4 \
114+
--retries=4

scripts/check-sitemap-parity.mjs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Verify that every URL currently listed in the production sitemap is still
4+
* reachable on a preview deployment (typically a Netlify PR preview).
5+
*
6+
* How it works:
7+
* 1. Downloads `${prodBase}/en/sitemap.xml` and `${prodBase}/de/sitemap.xml`
8+
* and extracts every `<loc>` URL.
9+
* 2. For each URL, keeps only the path (+ query, if any) and requests
10+
* `${previewBase}${path}` with redirect following. A final 2xx response
11+
* is a pass; anything else (3xx-terminal, 4xx, 5xx, network error) is a
12+
* fail.
13+
* 3. Also downloads the preview's own `/en/sitemap.xml` and `/de/sitemap.xml`
14+
* and reports which production paths are missing from them (informational
15+
* — a path can be missing from the preview sitemap and still reachable,
16+
* which the reachability check catches separately).
17+
*
18+
* Usage:
19+
* node scripts/check-sitemap-parity.mjs \
20+
* --preview-base=https://deploy-preview-42--my-site.netlify.app \
21+
* [--prod-base=https://open-elements.com] \
22+
* [--concurrency=8] \
23+
* [--timeout=15000]
24+
*
25+
* Exits 0 if every prod URL is reachable on the preview, 1 otherwise.
26+
*/
27+
28+
const DEFAULT_PROD_BASE = 'https://open-elements.com';
29+
const LOCALES = ['en', 'de'];
30+
const USER_AGENT =
31+
'Mozilla/5.0 (compatible; open-elements-sitemap-parity/1.0; +https://github.com/open-elements/open-elements-website)';
32+
const RETRY_STATUSES = new Set([403, 408, 425, 429, 500, 502, 503, 504]);
33+
34+
function parseArgs(argv) {
35+
const out = {
36+
prodBase: DEFAULT_PROD_BASE,
37+
previewBase: null,
38+
concurrency: 4,
39+
timeoutMs: 15000,
40+
retries: 3,
41+
};
42+
for (const arg of argv.slice(2)) {
43+
if (arg.startsWith('--prod-base=')) {
44+
out.prodBase = arg.slice('--prod-base='.length);
45+
} else if (arg.startsWith('--preview-base=')) {
46+
out.previewBase = arg.slice('--preview-base='.length);
47+
} else if (arg.startsWith('--concurrency=')) {
48+
out.concurrency = Number(arg.slice('--concurrency='.length));
49+
} else if (arg.startsWith('--timeout=')) {
50+
out.timeoutMs = Number(arg.slice('--timeout='.length));
51+
} else if (arg.startsWith('--retries=')) {
52+
out.retries = Number(arg.slice('--retries='.length));
53+
} else if (arg === '--help' || arg === '-h') {
54+
out.help = true;
55+
} else {
56+
throw new Error(`Unknown argument: ${arg}`);
57+
}
58+
}
59+
return out;
60+
}
61+
62+
function stripTrailingSlash(url) {
63+
return url.replace(/\/+$/, '');
64+
}
65+
66+
async function fetchText(url, timeoutMs) {
67+
const controller = new AbortController();
68+
const timer = setTimeout(() => controller.abort(), timeoutMs);
69+
try {
70+
const res = await fetch(url, {
71+
redirect: 'follow',
72+
signal: controller.signal,
73+
headers: { 'user-agent': USER_AGENT },
74+
});
75+
if (!res.ok) {
76+
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
77+
}
78+
return await res.text();
79+
} finally {
80+
clearTimeout(timer);
81+
}
82+
}
83+
84+
function extractLocs(xml) {
85+
const locs = [];
86+
const re = /<loc>([^<]+)<\/loc>/g;
87+
let m;
88+
while ((m = re.exec(xml)) !== null) {
89+
locs.push(m[1].trim());
90+
}
91+
return locs;
92+
}
93+
94+
function urlToPath(rawUrl) {
95+
try {
96+
const u = new URL(rawUrl);
97+
return `${u.pathname}${u.search}`;
98+
} catch {
99+
return null;
100+
}
101+
}
102+
103+
async function loadSitemapPaths(base, timeoutMs) {
104+
const paths = new Set();
105+
for (const locale of LOCALES) {
106+
const sitemapUrl = `${base}/${locale}/sitemap.xml`;
107+
const xml = await fetchText(sitemapUrl, timeoutMs);
108+
for (const loc of extractLocs(xml)) {
109+
const p = urlToPath(loc);
110+
if (p) paths.add(p);
111+
}
112+
}
113+
return paths;
114+
}
115+
116+
async function checkReachable(previewBase, pathAndQuery, timeoutMs, retries) {
117+
const target = `${previewBase}${pathAndQuery}`;
118+
let lastStatus = 0;
119+
let lastError;
120+
121+
for (let attempt = 0; attempt <= retries; attempt++) {
122+
if (attempt > 0) {
123+
const backoffMs = 500 * 2 ** (attempt - 1);
124+
await new Promise(resolve => setTimeout(resolve, backoffMs));
125+
}
126+
const controller = new AbortController();
127+
const timer = setTimeout(() => controller.abort(), timeoutMs);
128+
try {
129+
const res = await fetch(target, {
130+
redirect: 'follow',
131+
signal: controller.signal,
132+
headers: { 'user-agent': USER_AGENT },
133+
});
134+
if (res.ok) {
135+
return {
136+
ok: true,
137+
status: res.status,
138+
finalUrl: res.url,
139+
attempts: attempt + 1,
140+
};
141+
}
142+
lastStatus = res.status;
143+
if (!RETRY_STATUSES.has(res.status)) {
144+
return { ok: false, status: res.status, attempts: attempt + 1 };
145+
}
146+
} catch (err) {
147+
lastError = err.message ?? String(err);
148+
} finally {
149+
clearTimeout(timer);
150+
}
151+
}
152+
153+
return {
154+
ok: false,
155+
status: lastStatus,
156+
error: lastError,
157+
attempts: retries + 1,
158+
};
159+
}
160+
161+
async function runWithConcurrency(items, concurrency, worker) {
162+
const results = new Array(items.length);
163+
let cursor = 0;
164+
const runners = Array.from(
165+
{ length: Math.min(concurrency, items.length) },
166+
async () => {
167+
while (true) {
168+
const i = cursor++;
169+
if (i >= items.length) return;
170+
results[i] = await worker(items[i], i);
171+
}
172+
},
173+
);
174+
await Promise.all(runners);
175+
return results;
176+
}
177+
178+
async function main() {
179+
const args = parseArgs(process.argv);
180+
if (args.help || !args.previewBase) {
181+
console.error(
182+
'Usage: node scripts/check-sitemap-parity.mjs --preview-base=<url> [--prod-base=<url>] [--concurrency=N] [--timeout=ms]',
183+
);
184+
process.exit(args.help ? 0 : 2);
185+
}
186+
187+
const prodBase = stripTrailingSlash(args.prodBase);
188+
const previewBase = stripTrailingSlash(args.previewBase);
189+
190+
console.log(`Prod base: ${prodBase}`);
191+
console.log(`Preview base: ${previewBase}`);
192+
console.log(`Concurrency: ${args.concurrency}`);
193+
console.log('');
194+
195+
console.log('Downloading production sitemaps...');
196+
const prodPaths = await loadSitemapPaths(prodBase, args.timeoutMs);
197+
console.log(` ${prodPaths.size} unique paths in production sitemap.`);
198+
199+
console.log('Downloading preview sitemaps (informational)...');
200+
let previewPaths = new Set();
201+
try {
202+
previewPaths = await loadSitemapPaths(previewBase, args.timeoutMs);
203+
console.log(` ${previewPaths.size} unique paths in preview sitemap.`);
204+
} catch (err) {
205+
console.warn(
206+
` WARN: failed to fetch preview sitemap (${err.message ?? err}). ` +
207+
'Continuing with reachability check only.',
208+
);
209+
}
210+
211+
const missingFromPreviewSitemap = [...prodPaths].filter(
212+
p => previewPaths.size > 0 && !previewPaths.has(p),
213+
);
214+
if (missingFromPreviewSitemap.length > 0) {
215+
console.log('');
216+
console.log(
217+
`Paths in prod sitemap but NOT in preview sitemap (${missingFromPreviewSitemap.length}):`,
218+
);
219+
for (const p of missingFromPreviewSitemap) console.log(` - ${p}`);
220+
}
221+
222+
console.log('');
223+
console.log(`Checking reachability of ${prodPaths.size} paths on preview...`);
224+
225+
const pathList = [...prodPaths].sort();
226+
const results = await runWithConcurrency(
227+
pathList,
228+
args.concurrency,
229+
async pathAndQuery => {
230+
const r = await checkReachable(
231+
previewBase,
232+
pathAndQuery,
233+
args.timeoutMs,
234+
args.retries,
235+
);
236+
return { pathAndQuery, ...r };
237+
},
238+
);
239+
240+
const failures = results.filter(r => !r.ok);
241+
const okCount = results.length - failures.length;
242+
243+
console.log('');
244+
console.log(`Reachable: ${okCount} / ${results.length}`);
245+
246+
if (failures.length > 0) {
247+
console.log('');
248+
console.log(`Broken paths (${failures.length}):`);
249+
for (const f of failures) {
250+
const detail =
251+
f.error != null ? `error: ${f.error}` : `status: ${f.status}`;
252+
console.log(` ✗ ${f.pathAndQuery} (${detail})`);
253+
}
254+
process.exit(1);
255+
}
256+
257+
console.log('');
258+
console.log('All production URLs are reachable on the preview. ✓');
259+
}
260+
261+
main().catch(err => {
262+
console.error(err.stack ?? err.message ?? err);
263+
process.exit(1);
264+
});

0 commit comments

Comments
 (0)