The main article separates a successful Pages build from what a reader receives over HTTP. This exercise makes that gap small enough to inspect. One Python file starts an invented site on loopback, requests its pages, and reports both the final status and the destination of an old URL. It never calls the real site’s monitoring tools or public host.

Download post_deploy_lab.py. It needs Python 3.9 or newer and no extra packages. From a checkout, run the copy under html/assets/code; if you downloaded the file alone, use python3 post_deploy_lab.py instead.

See the Healthy Case

python3 html/assets/code/jekyll-site-tooling/post-09a/post_deploy_lab.py --scenario healthy

The lab serves a tiny home page, an article, its image, and an old article URL that redirects to the article. It checks the page and image responses, then follows the redirect and compares the final path with the intended article path. All checks pass, and the temporary server closes before the command exits.

Break the Asset

python3 html/assets/code/jekyll-site-tooling/post-09a/post_deploy_lab.py --scenario missing-image

The article still returns 200, but its image returns 404. The process exits 1. This is the kind of gap a build can miss if the deployed asset path is wrong or the uploaded artifact is incomplete.

Make a Redirect That Looks Healthy

python3 html/assets/code/jekyll-site-tooling/post-09a/post_deploy_lab.py --scenario wrong-redirect

This time the old URL redirects to the home page. A client following redirects receives 200, which looks fine if status is the only assertion. The lab also checks the final path and exits 1 because / is not /article/. That is why I keep the short-link and generated-redirect checks alongside any public HTTP probe. The site’s sitemap crawler follows redirects, but it does not currently assert the final destination.

Try changing the expected destination in the source and running the case again. You can see exactly which contract you have weakened. The lab is intentionally small; it is not a replacement for site_crawl_check.py, the repository’s Ruby redirect verifiers, or a full browser review.

The complete source is here to inspect or adapt:

post_deploy_lab.py python View source
#!/usr/bin/env python3
"""Serve a tiny site on loopback and check its public-side HTTP contracts."""

import argparse
import threading
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class Images(HTMLParser):
    def __init__(self):
        super().__init__()
        self.sources = []

    def handle_starttag(self, tag, attrs):
        if tag == "img":
            source = dict(attrs).get("src")
            if source:
                self.sources.append(source)


def make_handler(scenario):
    class TinySite(BaseHTTPRequestHandler):
        def do_GET(self):
            base = f"http://127.0.0.1:{self.server.server_port}"
            if self.path == "/sitemap.xml":
                body = (
                    '<?xml version="1.0" encoding="UTF-8"?>'
                    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
                    f"<url><loc>{base}/</loc></url>"
                    f"<url><loc>{base}/article/</loc></url>"
                    "</urlset>"
                ).encode()
                self.respond(200, body, "application/xml")
            elif self.path == "/":
                self.respond(200, b'<a href="/article/">Article</a>', "text/html")
            elif self.path == "/article/":
                self.respond(
                    200,
                    b'<h1>Article</h1><img src="/assets/diagram.svg" alt="Diagram">'
                    b'<a href="/old-article/">Old address</a>',
                    "text/html",
                )
            elif self.path == "/old-article/":
                self.send_response(302)
                self.send_header(
                    "Location", "/" if scenario == "wrong-redirect" else "/article/"
                )
                self.end_headers()
            elif self.path == "/assets/diagram.svg" and scenario != "missing-image":
                self.respond(
                    200,
                    b'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
                    "image/svg+xml",
                )
            else:
                self.respond(404, b"Missing", "text/plain")

        def respond(self, status, body, content_type):
            self.send_response(status)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, _format, *_args):
            pass

    return TinySite


def fetch(url):
    try:
        with urllib.request.urlopen(url, timeout=3) as response:
            return response.status, response.url, response.read()
    except urllib.error.HTTPError as error:
        return error.code, error.url, error.read()


def check(base):
    status, _final, sitemap = fetch(base + "/sitemap.xml")
    if status != 200:
        print(f"FAIL sitemap: HTTP {status}")
        return False

    root = ET.fromstring(sitemap)
    namespace = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
    pages = [node.text for node in root.findall(".//sm:loc", namespace)]
    good = True
    images = set()

    for page in pages:
        page_status, _final, body = fetch(page)
        print(f"{'PASS' if page_status == 200 else 'FAIL'} page {urllib.parse.urlparse(page).path}: HTTP {page_status}")
        good &= page_status == 200
        if page_status == 200:
            parser = Images()
            parser.feed(body.decode("utf-8"))
            images.update(urllib.parse.urljoin(page, src) for src in parser.sources)

    for image in sorted(images):
        image_status, _final, _body = fetch(image)
        print(f"{'PASS' if image_status == 200 else 'FAIL'} image {urllib.parse.urlparse(image).path}: HTTP {image_status}")
        good &= image_status == 200

    old_status, destination, _body = fetch(base + "/old-article/")
    final_path = urllib.parse.urlparse(destination).path
    correct_destination = old_status == 200 and final_path == "/article/"
    print(
        f"{'PASS' if correct_destination else 'FAIL'} old URL: "
        f"HTTP {old_status}, final path {final_path!r}, expected '/article/'"
    )
    good &= correct_destination
    return good


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--scenario",
        choices=("healthy", "missing-image", "wrong-redirect"),
        default="healthy",
    )
    scenario = parser.parse_args().scenario
    server = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(scenario))
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        print(f"Scenario: {scenario}")
        return 0 if check(f"http://127.0.0.1:{server.server_port}") else 1
    finally:
        server.shutdown()
        thread.join()
        server.server_close()


if __name__ == "__main__":
    raise SystemExit(main())