Skip to content

Commit 8d3f17e

Browse files
committed
add a validator
1 parent f4fefec commit 8d3f17e

File tree

2 files changed

+173
-3
lines changed

2 files changed

+173
-3
lines changed

.circleci/config.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,19 +213,26 @@ jobs:
213213
echo "[INFO] Deploying production site..."
214214
aws s3 sync "$BUILD_DIRECTORY" "s3://$AWS_S3_BUCKET/"
215215
- run:
216-
name: install pyyaml
216+
name: install pyyaml requests
217217
command: |
218218
set -e
219-
echo "[INFO] Installing pyyaml..."
220-
pip install pyyaml
219+
echo "[INFO] Installing pyyaml requests..."
220+
pip install pyyaml requests
221221
- run:
222222
name: Deploy Redirects to S3
223223
command: |
224224
AWS_S3_BUCKET=<< parameters.bucket_name >>
225225
226226
set -e
227227
echo "[INFO] Deploying redirects..."
228+
#REMEMBER TO UPDATE THE START/END PARAMETER IN THE VALIDATE JOB TO MATCH
228229
python scripts/create-redirects.py $AWS_S3_BUCKET --start 0 --end 10
230+
- run:
231+
name: Validate Redirects
232+
command: |
233+
set -e
234+
echo "[INFO] Validating redirects..."
235+
python scripts/validate-redirects.py https://circleci.com/docs-preview --start 0 --end 10
229236
- notify_error:
230237
message: "Production deployment job failed for branch ${CIRCLE_BRANCH}"
231238

scripts/validate-redirects.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
"""Validate that configured redirects respond correctly.
3+
4+
Reads the redirects defined in ``scripts/redirects_v2.yml`` (list of mappings
5+
with ``old`` and ``new`` keys).
6+
7+
For each entry it performs two checks:
8+
9+
1. A GET request to the *old* path returns an HTTP redirect (3xx).
10+
2. The redirect *Location* is followed and the final response returns a 200
11+
status code. This ensures the target page exists.
12+
13+
The script is intentionally lightweight and has no external dependencies other
14+
than the ``requests`` library.
15+
16+
Usage:
17+
python scripts/validate-redirects.py https://example.com [--start N] [--end M]
18+
19+
Passing a slice of the redirects is handy while developing.
20+
"""
21+
from __future__ import annotations
22+
23+
import argparse
24+
import sys
25+
import textwrap
26+
from pathlib import Path
27+
from typing import Iterable
28+
from urllib.parse import urljoin
29+
30+
try:
31+
import requests # type: ignore
32+
except ImportError:
33+
sys.stderr.write(
34+
textwrap.dedent(
35+
"""
36+
[ERROR] The 'requests' package is required to run this script.\n\n"
37+
" Install it with: pip install requests\n"
38+
"""
39+
)
40+
)
41+
sys.exit(1)
42+
43+
try:
44+
import yaml # type: ignore
45+
except ImportError:
46+
sys.stderr.write("[ERROR] PyYAML is required. Install with: pip install pyyaml\n")
47+
sys.exit(1)
48+
49+
ROOT_DIR = Path(__file__).resolve().parent.parent # repository root
50+
REDIRECTS_FILE = ROOT_DIR / "scripts" / "redirects_v2.yml"
51+
DEFAULT_TIMEOUT = 10 # seconds
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Helpers
56+
# ---------------------------------------------------------------------------
57+
58+
def load_redirects(path: Path) -> list[dict[str, str]]:
59+
"""Return redirects loaded from *path* (expects list of mapping)."""
60+
if not path.exists():
61+
raise FileNotFoundError(f"Redirects YAML not found: {path}")
62+
63+
with path.open("r", encoding="utf-8") as fh:
64+
data = yaml.safe_load(fh)
65+
66+
if not isinstance(data, list):
67+
raise ValueError("Redirects YAML should be a list of mappings.")
68+
69+
redirects: list[dict[str, str]] = []
70+
for item in data:
71+
if not isinstance(item, dict):
72+
continue
73+
old = item.get("old")
74+
new = item.get("new")
75+
if old and new:
76+
redirects.append({"old": str(old), "new": str(new)})
77+
return redirects
78+
79+
80+
def check_redirect(base_url: str, old_path: str) -> tuple[int, str]:
81+
"""Perform a request to *old_path* and return (status_code, location)."""
82+
url = urljoin(base_url, "docs-preview" + old_path)
83+
resp = requests.get(url, allow_redirects=False, timeout=DEFAULT_TIMEOUT)
84+
return resp.status_code, resp.headers.get("Location", "")
85+
86+
87+
def check_final(url: str) -> int:
88+
"""Follow redirect target URL and return status code."""
89+
resp = requests.get(url, allow_redirects=True, timeout=DEFAULT_TIMEOUT)
90+
return resp.status_code
91+
92+
93+
def slice_iter(iterable: Iterable, start: int, end: int | None) -> Iterable:
94+
"""Yield a slice [start:end] of *iterable* without loading it twice."""
95+
for idx, item in enumerate(iterable):
96+
if idx < start:
97+
continue
98+
if end is not None and idx >= end:
99+
break
100+
yield idx, item
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# Main
105+
# ---------------------------------------------------------------------------
106+
107+
def main() -> None:
108+
parser = argparse.ArgumentParser(description="Validate configured redirects")
109+
parser.add_argument(
110+
"base_url",
111+
help="Base URL/domain to test against, e.g. https://circleci.com",
112+
)
113+
parser.add_argument("--start", type=int, default=0, help="Start index (inclusive)")
114+
parser.add_argument("--end", type=int, default=None, help="End index (exclusive)")
115+
args = parser.parse_args()
116+
117+
redirects = load_redirects(REDIRECTS_FILE)
118+
end = args.end if args.end is not None else len(redirects)
119+
120+
print(
121+
f"[INFO] Validating redirects {args.start}-{end-1} / {len(redirects)-1} "
122+
f"against {args.base_url}..."
123+
)
124+
125+
failures: list[str] = []
126+
for idx, entry in slice_iter(redirects, args.start, end):
127+
old = entry["old"]
128+
status, location = check_redirect(args.base_url, old)
129+
print(f"[DEBUG] ({idx}) status={status}, location='{location}'")
130+
131+
if status // 100 != 3:
132+
failures.append(f"({idx}) {old}: expected 3xx, got {status}")
133+
print(f"[FAIL] ({idx}) {old}: expected 3xx, got {status}")
134+
continue
135+
136+
# Location could be absolute or relative
137+
absolute_location = urljoin(args.base_url, location)
138+
final_status = check_final(absolute_location)
139+
print(f"[DEBUG] ({idx}) final_status={final_status}")
140+
if final_status != 200:
141+
failures.append(
142+
f"({idx}) {old}: target returned status {final_status} (expected 200)"
143+
)
144+
print(
145+
f"[FAIL] ({idx}) {old}: target returned status {final_status} "
146+
f"(expected 200)"
147+
)
148+
continue
149+
150+
print(f"[PASS] ({idx}) {old} -> {location} [OK]")
151+
152+
print("\n[SUMMARY]")
153+
if failures:
154+
print(f"❌ {len(failures)} failures detected:")
155+
for msg in failures:
156+
print(" - " + msg)
157+
sys.exit(1)
158+
else:
159+
print("✅ All redirects validated successfully!")
160+
161+
162+
if __name__ == "__main__":
163+
main()

0 commit comments

Comments
 (0)