Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/redirects.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Live redirect checks

on:
workflow_dispatch:
schedule:
- cron: "43 8 * * 2"

permissions:
contents: read

jobs:
redirects:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Check deployed redirects and canonical destinations
run: python3 scripts/check-redirects.py
2 changes: 1 addition & 1 deletion .github/workflows/terraform-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ jobs:
Trigger: `${{ github.event_name }}`
Reason: ${{ inputs.reason || 'Scheduled weekly refresh' }}

The generated module configuration, cards, and structured data are included in this pull request.
The generated module configuration, cards, structured data, and sitemap date are included in this pull request.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,24 @@ Run the repository SEO audit locally:
python3 scripts/seo-audit.py
```

After publishing, check live redirect chains and final canonical destinations:

```sh
python3 scripts/check-redirects.py
```

The separate `Live redirect checks` workflow checks production weekly and on
manual runs. The local SEO audit also checks internal canonical links and
fragments, distinct titles and descriptions, structured-data URLs, and sitemap
freshness for the generated Terraform catalogue.

The `Lighthouse and SEO` GitHub Actions workflow audits every canonical page
on pull requests and pushes to `main`. It enforces minimum performance,
accessibility, best-practices, and SEO scores together with lab thresholds for
Largest Contentful Paint, cumulative layout shift, and total blocking time. It
also validates HTML and checks internal fragments and external links.

Google Search Console requires account-issued ownership verification. Follow
[`SEARCH_CONSOLE.md`](./SEARCH_CONSOLE.md) to add the URL-prefix property,
install the unmodified verification file, submit the sitemap, and request
indexing for each canonical page.
[`SEARCH_CONSOLE.md`](./SEARCH_CONSOLE.md) to verify a Domain property, retain
the unmodified verification file, submit the sitemap, and distinguish expected
redirect exclusions from actual redirect errors.
92 changes: 89 additions & 3 deletions SEARCH_CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
The repository is ready for Google Search Console: every indexable page has one
self-referencing `https://native-cube.com/.../` canonical, explicit index/follow
directives, matching Open Graph and structured-data URLs, and an entry in
`sitemap.xml`. `robots.txt` advertises that sitemap, while the duplicate
`/json-formatter/` alias remains excluded from indexing.
`sitemap.xml`. `robots.txt` advertises that sitemap. The duplicate
`/json-formatter/` alias points directly to `/yaml-formatter/` through an instant
meta refresh and a canonical link; it is excluded from the sitemap and navigation.

Ownership verification must be completed by a Google account with access to
Search Console. The verification value is account-issued and must not be
Expand Down Expand Up @@ -35,7 +36,7 @@ invented or replaced with a placeholder.
- `https://native-cube.com/visual-subnet-calculator/`
- `https://native-cube.com/yaml-formatter/`
- `https://native-cube.com/terraform-modules/`
4. Do not submit `/json-formatter/`; it is a `noindex` convenience alias.
4. Do not submit `/json-formatter/`; it is a permanent redirect alias.

## Confirm Google's selected canonical

Expand All @@ -49,3 +50,88 @@ sitemap.

The account owner can provide the downloaded verification file to a
contributor for placement, but should not share Google credentials.

## Redirect exclusions versus redirect failures

Search Console's **Page with redirect** is expected for HTTP, `www`, the old
GitHub Pages hostname, paths missing their trailing slash, and `/json-formatter/`.
Google indexes the destination instead. Those source URLs should remain
excluded; repeatedly validating them as indexable pages will not fix anything.

**Redirect error** is different: Google could not follow a chain because of a
loop, excessive redirects, an invalid or empty destination, or an overly long
URL. Copy the exact affected URL from the report and inspect every hop and the
final destination. **Alternate page with proper canonical tag** is also expected
for `/index.html` duplicates that GitHub Pages serves with HTTP 200.

These distinctions are documented in Google's
[Page indexing report help](https://support.google.com/webmasters/answer/7440203).

## Check the deployed site

Run the dependency-free live audit after deployment:

```sh
python3 scripts/check-redirects.py
```

It checks every sitemap page across HTTP/HTTPS, apex/`www`, and the old GitHub
Pages hostname, plus slashless paths, `index.html` duplicates, and formatter
aliases. It follows HTTP and instant meta-refresh redirects explicitly, rejects
loops and temporary or insecure redirects, and checks that each final page is
HTTP 200 with the expected canonical and no indexing restrictions. The separate
**Live redirect checks** workflow runs weekly and can be started manually in
Actions. It checks production, so run it after the source changes are published.

An optional user agent helps investigate response differences:

```sh
python3 scripts/check-redirects.py --user-agent Googlebot
```

Changing a user-agent string does not reproduce Google's crawler network or
replace Search Console's **Test live URL**.

Public HTTP checks on 2 November 2026 confirmed that all eight canonical pages
returned 200 and the tested HTTP, `www`, and GitHub Pages variants redirected
permanently to the apex HTTPS URLs. The formatter alias still served the old
`noindex` directive before this change was deployed. This is a consolidation
signal issue; it does not prove the cause of an account's **Redirect error**.

## Hosting settings and the formatter alias

Keep **Settings → Pages → Custom domain** set to `native-cube.com` and
**Enforce HTTPS** enabled. The repository's `CNAME` file does not by itself
enforce HTTPS or configure DNS. Both the apex and `www` DNS records must point
to GitHub Pages for GitHub's automatic hostname redirect and HTTPS certificate
to work. See GitHub's
[custom-domain configuration](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site)
and [HTTPS settings](https://docs.github.com/en/pages/getting-started-with-github-pages/securing-your-github-pages-site-with-https).

GitHub Pages serves static HTML and does not provide per-path HTTP redirect
rules in this repository. The formatter alias therefore uses an instant meta
refresh, which Google treats as a permanent redirect, plus a matching absolute
canonical and fallback link. JavaScript preserves query strings and fragments
for visitors. See Google's
[redirect guidance](https://developers.google.com/search/docs/crawling-indexing/301-redirects).
An HTTP 301 for this alias would require redirect support at a hosting or proxy
layer. Do not add redirect files for other hosting services and expect GitHub
Pages to apply them.

Do not use `noindex` to consolidate the alias with its destination; rely on the
redirect and canonical together. Google's
[canonicalization guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)
explains why `noindex` is unsuitable for canonical selection.

After publishing, rerun the live audit, resubmit the canonical sitemap, and use
**Test live URL** for each affected destination. Request indexing for canonical
pages and validate actual **Redirect error** fixes. Historical exclusions remain
until Google recrawls them, and intentional redirects remain excluded.

## Keep sitemap dates accurate

Update a page's `lastmod` when its public content or metadata changes. The
Terraform renderer updates its sitemap date from the published catalogue's
`synced_at` value while preserving a later manual edit date. Include `sitemap.xml`
in catalogue update commits. The local SEO audit rejects a Terraform sitemap
date older than the catalogue. Do not reset every date simply because CI ran.
2 changes: 1 addition & 1 deletion argocd-applicationset-studio/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
<section class="hero">
<div class="hero-intro">
<p class="eyebrow">Argo CD / Fleet engineering</p>
<h1>Preview the fleet.<br /><em>Ship with confidence.</em></h1>
<h1>Preview Argo CD ApplicationSets.<br /><em>Review your fleet.</em></h1>
</div>
<div class="hero-copy">
<p>
Expand Down
2 changes: 1 addition & 1 deletion helm-chart-builder/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
<section class="hero">
<div>
<p class="eyebrow">Helm / Chart authoring</p>
<h1>Shape the values.<br /><em>Generate the chart.</em></h1>
<h1>Build a Helm chart.<br /><em>Download it in seconds.</em></h1>
</div>
<div class="hero-copy">
<p>
Expand Down
7 changes: 3 additions & 4 deletions json-formatter/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta http-equiv="refresh" content="0; url=../yaml-formatter/" />
<meta http-equiv="refresh" content="0; url=https://native-cube.com/yaml-formatter/" />
<link
rel="canonical"
href="https://native-cube.com/yaml-formatter/"
/>
<title>Redirecting to YAML &amp; JSON Formatter | Native Cube</title>
<script>
const destination = new URL("../yaml-formatter/", window.location.href);
const destination = new URL("https://native-cube.com/yaml-formatter/");
destination.search = window.location.search;
destination.hash = window.location.hash;
window.location.replace(destination);
Expand All @@ -20,7 +19,7 @@
<body>
<p>
Redirecting to the
<a href="../yaml-formatter/">YAML &amp; JSON Formatter</a>.
<a href="https://native-cube.com/yaml-formatter/">YAML &amp; JSON Formatter</a>.
</p>
</body>
</html>
2 changes: 1 addition & 1 deletion kubernetes-rbac-explorer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
<section class="hero">
<div>
<p class="eyebrow">Kubernetes / Access control</p>
<h1>See who can do what.<br /><em>Know exactly why.</em></h1>
<h1>Explore Kubernetes RBAC.<br /><em>See who can do what.</em></h1>
</div>
<p class="hero-copy">
Turn Roles and bindings into an understandable access map. Test an
Expand Down
157 changes: 157 additions & 0 deletions scripts/check-redirects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Check deployed HTTP and instant meta-refresh redirects without dependencies."""

import argparse
from concurrent.futures import ThreadPoolExecutor
from html.parser import HTMLParser
from pathlib import Path
import re
import sys
from urllib.error import HTTPError
from urllib.parse import urljoin, urlsplit
from urllib.request import build_opener, HTTPRedirectHandler, Request
import xml.etree.ElementTree as ET


ROOT = Path(__file__).resolve().parent.parent
SITE_URL = "https://native-cube.com"
HOSTS = (
SITE_URL,
"http://native-cube.com",
"https://www.native-cube.com",
"http://www.native-cube.com",
"https://native-cube.github.io",
"http://native-cube.github.io",
)
PERMANENT = {301, 308}


class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None


class Page(HTMLParser):
def __init__(self):
super().__init__()
self.canonicals = []
self.refreshes = []
self.robots = []

def handle_starttag(self, tag, attrs):
values = dict(attrs)
if tag == "link" and "canonical" in values.get("rel", "").lower().split():
self.canonicals.append(values.get("href", ""))
if tag == "meta":
if values.get("http-equiv", "").lower() == "refresh":
self.refreshes.append(values.get("content", ""))
if values.get("name", "").lower() in {"robots", "googlebot"}:
self.robots.append(values.get("content", "").lower())


def fetch(url, user_agent):
opener = build_opener(NoRedirect)
try:
response = opener.open(Request(url, headers={"User-Agent": user_agent}), timeout=20)
except HTTPError as error:
response = error
with response:
return response.status, response.headers, response.read().decode("utf-8", "replace")


def check_url(source, expected, user_agent):
current = source
seen = set()
chain = []
try:
for _ in range(6):
if current in seen:
raise ValueError("redirect loop")
seen.add(current)
status, headers, body = fetch(current, user_agent)
chain.append(f"{status} {current}")
if 300 <= status < 400:
if status not in PERMANENT:
raise ValueError(f"expected a permanent redirect, got {status}")
location = headers.get("Location")
if not location:
raise ValueError("redirect has no Location header")
destination = urljoin(current, location)
elif status == 200:
page = Page()
page.feed(body)
directives = page.robots + [headers.get("X-Robots-Tag", "").lower()]
if any("noindex" in value or "nofollow" in value for value in directives):
raise ValueError("indexing or following is blocked")
if page.refreshes:
if len(page.refreshes) != 1:
raise ValueError("multiple meta-refresh redirects")
match = re.fullmatch(r"0\s*;\s*url=(.+)", page.refreshes[0], re.I)
if not match:
raise ValueError("meta refresh must be an instant permanent redirect")
destination = urljoin(current, match[1].strip("\"'"))
if destination != expected or page.canonicals != [expected]:
raise ValueError("meta refresh and canonical must point directly to target")
chain.append(f"meta refresh -> {destination}")
else:
# GitHub Pages serves /index.html duplicates with 200. Their
# canonical consolidates them without a client-side redirect.
duplicate = expected + "index.html"
if current not in {expected, duplicate}:
raise ValueError(f"chain ended at {current}, expected {expected}")
if page.canonicals != [expected]:
raise ValueError(f"expected exactly one canonical: {expected}")
if "text/html" not in headers.get("Content-Type", ""):
raise ValueError("target is not served as HTML")
return True, " -> ".join(chain)
else:
raise ValueError(f"unexpected HTTP {status}")
target = urlsplit(destination)
if target.hostname not in {"native-cube.com", "www.native-cube.com", "native-cube.github.io"}:
raise ValueError(f"redirect leaves the site: {destination}")
if target.scheme != "https":
raise ValueError(f"redirect does not use HTTPS: {destination}")
current = destination
raise ValueError("more than five redirects")
except Exception as error:
return False, f"{' -> '.join(chain) or source}: {error}"


def redirect_cases():
namespace = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
sitemap = ET.parse(ROOT / "sitemap.xml")
cases = {}
for node in sitemap.findall("s:url/s:loc", namespace):
canonical = node.text
if not canonical or not canonical.startswith(SITE_URL + "/"):
raise ValueError("sitemap contains a non-canonical URL")
path = urlsplit(canonical).path
for host in HOSTS:
cases[host + path] = canonical
cases[canonical + "index.html"] = canonical
if path != "/":
cases[canonical.rstrip("/")] = canonical
target = SITE_URL + "/yaml-formatter/"
for host in HOSTS:
cases[host + "/json-formatter/"] = target
cases[SITE_URL + "/json-formatter"] = target
cases[SITE_URL + "/json-formatter/index.html"] = target
return list(cases.items())


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--user-agent", default="NativeCubeSEOAudit/1.0")
args = parser.parse_args()
cases = redirect_cases()
with ThreadPoolExecutor(max_workers=6) as pool:
results = list(pool.map(lambda case: check_url(*case, args.user_agent), cases))
for passed, detail in results:
print(f"{'PASS' if passed else 'FAIL'} {detail}")
failures = sum(not passed for passed, _ in results)
print(f"Live redirect audit: {len(results) - failures}/{len(results)} passed")
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading