// open source
one-hop
- Install
pip install one-hop- Runs on
- Python 3.10 or later
- Licence
- MIT, free to use
one-hop checks a redirect map after a site move. Every old URL has to answer with one permanent redirect (301 or 308) straight to its new URL. The new URL has to answer 200. A chain, a loop, a 404 or a redirect to the wrong page fails the check.
pip install one-hop
one-hop redirects.csv --base https://example.com
Why we wrote it
We moved this site from WordPress to a static build on 23 September 2026. The redirect map listed 2,026 old addresses. A browser follows a chain of redirects without complaint. So a map can look right and still send every old URL through two or three hops. Google’s crawlers follow up to 10 hops and then stop. Every extra hop is one more request for each old URL.
So we checked every rule, on staging before the move and on the live site after it. The write-up is Moving an 8-year-old WordPress site without losing a single URL. This tool is that check, made general.
What you give it
A CSV with the old URL first and the new URL second:
old,new
/blog/old-post,/blog/new-post/
/about-us,/about/
/pricing,
An empty new URL means the page should answer 200 with no redirect. Relative
URLs are read against --base.
What you get back
Against a test server with five rules:
checking 5 rules against https://example.com
1/5 OK
loop: 1
not_found: 2
chain: 1
FAIL [loop] line 5: /team redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
FAIL [not_found] line 4: /services new URL returned 404
FAIL [not_found] line 6: /pricing old URL returned 404, no redirect
FAIL [chain] line 3: /about-us reaches the new URL after 2 redirects: ...
The exit code is 1 when any rule fails, so a CI job stops the release. --json
writes every hop of every rule to a file.
Details that catch people out
Trailing slashes count. /about and /about/ are two URLs, and a server rule
that adds the slash turns every redirect in your map into a chain.
302 and 307 are temporary redirects. one-hop fails them unless you pass
--allow-temporary.
Staging sites often sit behind a password. Pass it with --auth. It goes to
your staging host only, never to a host a redirect points at.
Running it in CI
Every job below fails when any rule fails. Point --base at staging before
the move and at the live site after it. For a staging host behind a password,
store user:password as a secret named ONE_HOP_AUTH. one-hop reads it from
the environment, so it stays out of the command and the log.
GitHub Actions
- uses: synapsereality/one-hop@v0.1.0
with:
csv: redirects.csv
base: https://staging.example.com
auth: ${{ secrets.STAGING_AUTH }}
GitLab CI
In .gitlab-ci.yml, with ONE_HOP_AUTH added as a masked CI/CD variable and
“Expand variable reference” unticked:
redirects:
image: python:3.13-slim
script:
- pip install one-hop==0.1.0
- one-hop redirects.csv --base https://staging.example.com --json redirects.json
artifacts:
when: always
paths:
- redirects.json
Bitbucket Pipelines
In bitbucket-pipelines.yml, with ONE_HOP_AUTH added as a secured repository
variable:
image: python:3.13-slim
pipelines:
default:
- step:
name: Check the redirect map
script:
- pip install one-hop==0.1.0
- one-hop redirects.csv --base https://staging.example.com
Any other CI
Anything that runs a shell command and reads its exit code works, with Python 3.10 or later:
python3 -m venv .one-hop
.one-hop/bin/pip install one-hop==0.1.0
.one-hop/bin/one-hop redirects.csv --base https://staging.example.com
Exit codes
| Code | Meaning |
|---|---|
| 0 | Every rule passed. |
| 1 | At least one rule failed. A host that refuses the connection or times out counts as a failure too. A crash also gives 1. |
| 2 | Nothing was checked: the CSV couldn’t be read or had no rules, --base wasn’t a full URL, or a flag was wrong. |
The README has where to add the secret in each CI and a shell step that tells codes 1 and 2 apart.
Related
- Which redirect status code to use when you move a site. What RFC 9110 and Google say about 301, 302, 303, 307, 308 and meta refresh
- Technical SEO, where migrations are part of the work
- Open source, everything else we have published or had merged