This morning I moved the blog repository from one account to another. Half an hour later the publishing pipeline was still running: the build passed, an article shipped, the social job ran. I should have been relieved.
I wasn't. Because when nothing breaks after a migration, it means one of two things: either I did the move cleanly, or the thing that would have broken wasn't running in the first place. From the outside those two look identical. The only way to tell them apart is to ask why the unbroken thing didn't break.
I asked. It was the second one. Then I found one more thing, but I'll get to that at the end.
The redirect really does work — but its shape matters
When you transfer a repository, GitHub keeps the old address alive. That's easy to measure, so I called the old path directly.
A read request returns a permanent redirect:
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" \
-H "Authorization: token $TOKEN" \
https://api.github.com/repos/mustafa_itwise/mustafaerbay
301 -> https://api.github.com/repositories/1370380801
A request with a body, sent with the same credentials, returns a temporary one:
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" -X POST \
-H "Authorization: token $TOKEN" -d '{"ref":"main"}' \
https://api.github.com/repos/mustafa_itwise/mustafaerbay/actions/workflows/999999999/dispatches
307 -> https://api.github.com/repositories/1370380801/actions/workflows/999999999/dispatches
Two details caught my eye. First, the redirect target isn't the new name — it's the repository's numeric id: /repositories/1370380801. GitHub sends you to the immutable number underneath the name rather than to the name itself, which makes sense, because the name can change again.
Second, the read and the write come back with different codes. RFC 9110 says a client following a 307 must not change the request method; a POST has to stay a POST. A 301 carries no such guarantee. Let me be careful here, though: I could not find a commitment in GitHub's documentation that requests with a body get a 307. All I have is the two probes above. So this is the behaviour I saw, not a rule. Check it on your own endpoint.
Then I tried three clients against those redirects. All three sailed straight through:
$ gh api repos/mustafa_itwise/mustafaerbay --jq '.full_name'
itwise-bilisim/mustafaerbay
$ git ls-remote https://github.com/mustafa_itwise/mustafaerbay.git HEAD
c910ca2c5f9f4fb1469d7fd5e5dc28ee41cf7f11 HEAD
$ curl -sL -o /dev/null -w "code=%{http_code} redirects=%{num_redirects} final=%{url_effective}\n" \
-X POST -H "Authorization: token $TOKEN" -d '{"ref":"main"}' \
https://api.github.com/repos/mustafa_itwise/mustafaerbay/actions/workflows/999999999/dispatches
code=404 redirects=1 final=https://api.github.com/repositories/1370380801/actions/workflows/999999999/dispatches
gh (2.86.0) was asked about the old name, answered with the new one, and didn't even feel the need to mention that a redirect happened along the way. git ls-remote fetched the current HEAD from the old URL — the same commit as origin in my main checkout. curl -L carried the POST to the target without mangling the method; that closing 404 comes from the workflow id genuinely not existing (I invented it for the probe), not from the redirect.
So everything appearing to work after a transfer is no accident. GitHub put real effort into that.
Who pays for the compatibility layer
This is worth pausing on, because the good news comes in two parts.
The good part: nothing breaks immediately. The bad part: because nothing breaks immediately, you never learn which caller has the old name baked into it. Breakage is the thing that builds the inventory for you. The redirect suppresses that inventory.
GitHub's own documentation is remarkably quiet here. The transfer page explains that links and git operations are redirected and suggests running git remote set-url on your local clone "to avoid confusion" — it says nothing about automation risk. The genuinely unsettling sentence is elsewhere on the same page: if a new repository or fork is created at the previous location, the redirects are permanently deleted. So this compatibility layer isn't a guarantee, it's a lease. If someone reuses the old name one day — entirely possible inside an organization account — the lease ends and every debt accumulated until then comes due at once.
The REST API guide, to its credit, says the right thing: on a 301, repeat the request against the new address and update your code; on a 307, repeat it but don't update. That "update your code" clause describes the redirect not as a permanent fix but as a clock counting down a transition period.
I had never read that clock.
The three scripts on the server
I searched the server first. Under /usr/local/sbin exactly three scripts turned up: cg-trigger.sh, workflow-watchdog.sh and pipeline-health.sh. The third one's only relationship with the old name is a link it writes into an alert email body. That leaves two scripts that will actually reach the API, and both use curl.
The old name survives in the repository too — far more than I expected: 18 hits across 13 files. Most are documentation (BLUEPRINT.md, AGENTS.md, deploy/README.md), the four files of the retired runner stack, and the setup template scripts/personalize.sh. Those don't call anything; they just set a bad example. deploy/cg-trigger.sh and deploy/pipeline-health.sh, on the other hand, are tracked and current in version control; the copies on the server come from them.
What --fail does not catch
This is exactly where shell scripts part ways with gh. Unless told otherwise, curl does not follow redirects. So what happens when it doesn't? I had assumed the -fsS flags would protect me; --fail means "blow up on an HTTP error," after all.
So I ran it:
$ curl -fsS -X POST -H "Authorization: Bearer $PAT" -d '{"ref":"main"}' \
"https://api.github.com/repos/mustafa_itwise/mustafaerbay/actions/workflows/999999999/dispatches" \
>/dev/null 2>&1; echo "exit=$?"
exit=0
Zero. Nothing was dispatched, nothing was queued, and curl reported success. When I sent the same request to the new address — where there's a genuine 404 — the exit code came back non-zero. The reason is simple and documented in curl's own manual: --fail engages for response codes of 400 and above. A 307 isn't an error. Without -L, it isn't a success either. It sits in between, where nobody is looking.
This is the worst thing that can happen to a trigger: any script written as if curl …; then logger "dispatched" will write "dispatched" to its log the moment it talks to the old name. Right word, wrong reality.
Except my script never even gets there
deploy/cg-trigger.sh is written in precisely that pattern:
if curl -fsS -X POST -H "Authorization: Bearer ${PAT}" -H "Accept: application/vnd.github+json" \
"${API}/actions/workflows/content-generate.yml/dispatches" -d '{"ref":"main"}' >/dev/null 2>&1; then
logger -t cg-trigger "content-generate dispatched (stale ${AGE}min > ${STALE_MIN})"
else
logger -t cg-trigger "ERR: dispatch FAILED (stale ${AGE}min)"
fi
But that block sits at the end of the script. Before it there's a read step, and that's where I actually lose the script:
LAST="$(curl -fsS … "${API}/contents/scripts/.last-generated?ref=main" 2>/dev/null | tr -d '[:space:]')"
[ -n "$LAST" ] || { logger -t cg-trigger "WARN: .last-generated okunamadi"; exit 0; }
(That warning is Turkish for "could not read .last-generated.") It looks reasonable: stop if the body is empty. Except a 301 response body isn't empty — GitHub puts a JSON error object there. I pulled the real response; 254 bytes:
$ curl -s -H "Authorization: Bearer $PAT" -H "Accept: application/vnd.github.raw" \
"https://api.github.com/repos/mustafa_itwise/mustafaerbay/contents/scripts/.last-generated?ref=main"
{
"message": "Moved Permanently",
"url": "https://api.github.com/repositories/1370380801/contents/scripts/.last-generated?ref=main",
"documentation_url": "https://docs.github.com/rest/guides/best-practices-for-using-the-rest-api#follow-redirects"
}
Strip the whitespace and 240 characters remain. So the [ -n "$LAST" ] check passes. The script's only line of defence is defeated by the exact condition it was meant to catch. I reproduced what follows on the server itself (GNU coreutils 9.4):
length=240
guard [-n]: PASSED
date: invalid date ‘{"message":"MovedPermanently","url":"https://api.github.com/repositories/...'
bash: ( 1790603819 - ) / 60 : syntax error: operand expected
bash: AGE: unbound variable
The chain runs like this: the emptiness check passes, date refuses to read JSON as a timestamp and prints nothing, the arithmetic expression hits a syntax error with a missing operand, AGE is never assigned, and set -u kills the script the moment that variable is touched.
Which means the fake "dispatched" line I described in the previous section is never written for this script — it dies before reaching that block. False success is the risk for scripts that trigger directly, with no read step in front. Mine fails more quietly than that: it reaches neither of its two logger branches, and since cron sends its output to /dev/null, even the error message evaporates. The watchdog doesn't get far enough to believe it's on duty.
The second script, workflow-watchdog.sh, is accidentally better written. At the dispatch step it captures the status code with -w '%{http_code}' and compares it against [ "$DISPATCH" = "204" ]. A 307 would have been caught there — because the question isn't "was this an error?" but "is this what I expected?" It never gets that far, though: its own read step has no -L either, the returned 301 body has no workflow_runs key, and the script logs WARN: Son successful run zamani alinamadi (API hatasi mi?) — "couldn't get the last successful run time (an API error?)" — and exits 0. The question mark at the end of that warning reads like proof that I wasn't sure even as I wrote the line.
The rule that falls out of this holds regardless of any migration: don't ask "did I get an error?", ask "did I get the answer I expected?" The first question misses 3xx. The second doesn't.
So why didn't anything break
Here's the honest answer. Neither of those scripts runs today:
$ crontab -l | grep cg-trigger
#*/30 * * * * /usr/local/sbin/cg-trigger.sh >/dev/null 2>&1
$ journalctl -t cg-trigger --since "3 days ago"
-- No entries --
$ systemctl is-enabled workflow-watchdog.timer; systemctl is-active workflow-watchdog.timer
disabled
inactive
$ tail -1 /var/log/workflow-watchdog.log
2026-05-15 09:12:41 | OK: workflow_dispatch tetigi gonderildi (HTTP 204)
The crontab line starts with a #. The timer is off. The last thing ever written to the watchdog's log is dated 15 May 2026 — four and a half months of silence.
Both became redundant when the blog moved into containers: switched off, never deleted. The reason this morning's transfer broke nothing isn't my migration hygiene; it's that the things that would have broken were already dark. That was luck. Luck and competence render the same shade of green on the dashboard, and the difference only shows up next time.
The only member of the trio still standing is pipeline-health.timer, which ran at 16:01 today. That's where the link I promised to come back to lives. Click it and the browser takes me to the new address, so technically nothing is broken. But an alert email is the one document people read while panicking; six months from now it will teach the wrong address to whoever opens it, which is to say, to me.
The fourth caller, found while writing this
I finished the section above and sat back, pleased with my inventory. Then I searched the repository properly, outside /usr/local/sbin, and this came up — src/pages/admin/ops.astro, my own ops panel:
// Public repo, auth gerek yok. Astro SSR runtime fetch kullanabilir.
const apiUrl = 'https://api.github.com/repos/mustafa_itwise/mustafaerbay/actions/runs?per_page=15';
The top of that file says export const prerender = false;. So this line doesn't run once at build time — it runs again every time the panel is opened. Not a disabled script. A live page.
And the comment is wrong. It claims the repo is public and needs no auth; the repo has been private since 14 September. I checked what an unauthenticated request gets:
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/mustafa_itwise/mustafaerbay/actions/runs?per_page=15"
404 ->
No redirect at all. GitHub doesn't tell an anonymous caller that a private repository has moved — it returns 404 as though no such repo ever existed. Which is right: otherwise the redirect would be a service that leaks the new names of private repositories. Sending the same request to the new address gets me a 404 as well. On the code's side the consequence is this: res.ok is false, workflowRuns stays an empty array, and the panel renders an empty Actions table. No error, nothing red. An empty table.
And here's the part that stings: none of this has anything to do with the transfer. The panel has been showing an empty table since the day the repo went private. I went looking for something broken by today's move and found something that's been broken for four months — in the middle of a discussion about redirects, a caller the redirect never touched.
So I need to correct my own thesis. "The thing that would have broken was already switched off" is incomplete. The accurate version: what was switched off didn't break, what was live was already broken, and an empty table is quiet enough that nobody noticed. And I missed it on the first pass, while believing I'd taken the inventory.
A transfer has a loud side too
Everything so far has been about silent breakage, because that's what frightens me. But in fairness a transfer has a loud side as well, and anyone acting on this article should know about it. These are the places the redirect does not save you.
Fine-grained access tokens are bound to individual repositories; when a repo moves to another account, the token's scope does not move with it. The uses: old-account/repo@ref references in your workflows, and any reusable workflows, resolve by name. Package paths (ghcr.io/old-account/...) are an entirely different namespace. Webhooks, deploy keys and runner registrations are tied to the target repo's identity too. Most of these shout at you when they break — which is the good news; they don't quietly return the wrong answer.
And one question with no answer: there's no way to learn when the redirect will end. I couldn't find an endpoint that tells you who might break the lease, or when. The only defence is not to rely on the lease at all.
What to actually do after a transfer
-
Search for the old name on every machine — and in your application code. I scanned
/usr/local/sbin, decided I'd done the right thing, and skipped the SSR page.grep -rn "old-name" .alongsidegrep -rl "old-name" /usr/local/sbin /etc/systemd/system /etc/cron.dtakes thirty seconds together. - Count the automation that's switched off, too. A disabled script is not a deleted script. Someone — usually you, six months later — turns it back on, and that's the day it starts lying quietly.
- Probe the redirect by hand, once. Send one read and one write to the old path and look at the raw status code. Do it authenticated and anonymous separately; as you've seen, they don't behave the same way.
-
Read the status code on your
curlcalls. The practical pattern: take-sS -w '%{http_code}'and compare against the code you expect; or, if you want redirects followed,-L --fail-with-body.--failalone does not see 3xx. - Update the addresses in alert messages — and the comments. "Public repo, no auth needed" outlived the reality it described, and it pointed me in the wrong direction.
None of this is clever. That's rather the point.
Closing
Compatibility layers buy us time, but they pay for it out of a different account: how easy the next diagnosis will be. As long as the old name keeps working, you cannot measure which code depends on it.
But today's real lesson isn't even about redirects. This morning I asked "did the transfer break anything?", answered "no," and very nearly ended the article there. That turned out to be the wrong question. The right one was: who calls this name? The first question found me two disabled scripts and a sense of relief. The second found a panel that has been drawing an empty table for four months.
Only yesterday, writing about a change I reverted in ninety seconds that went on doing damage for five more hours, I reached a similar place — but the difference matters: there I had noticed the incident, here I hadn't. Not even the day I counted what I had deliberately left un-automated across seven servers did these three scripts make the list.
You cannot tell whether a migration went well by looking at what didn't break. You have to find the thing that should have broken and didn't, and ask why. If the answer is "because I did it right," wonderful. If the answer is "because that part was already dead," then what you're holding isn't a success — it's an appointment. And sometimes the answer is "because it was already broken and nobody was looking," which is worse than both.
Top comments (0)