The auto-update ran fine. It just ran at the wrong time.
Last month I set up auto-update for my self-hosted CPA (CLIProxyAPI, a Claude proxy service): every day at 4 AM, a script pulls the upstream code, compiles, and restarts the service. I thought the time was clever — the middle of the night, nobody’s using it, plenty of room for a restart window.
This morning the update ran as usual and bumped me to v7.2.130. Then at around 2 PM the upstream shipped v7.2.131.
A few hours late, and I had to trigger it by hand again.
The auto-update wasn’t broken — my schedule and the upstream’s release rhythm were simply on different channels. So I decided to count, hour by hour, when upstream actually ships, and then set the alarm clock properly. The method is plain: pull the published times of the last 60 releases and the push times of the last 80 commits with gh, convert everything to Beijing time, and tally by hour.
Hourly counts: two tables that reveal a routine
Table 1: distribution by hour (last 60 releases + last 80 commits, Beijing time)
| Hour | Releases | Share | Commits | Share |
|---|---|---|---|---|
| 00 | 6 | 10% | 6 | 7.5% |
| 01 | 2 | 3% | 6 | 7.5% |
| 02 | 5 | 8% | 1 | 1% |
| 03 | 5 | 8% | 2 | 2.5% |
| 04 | 2 | 3% | 4 | 5% |
| 05 | 5 | 8% | 5 | 6% |
| 06 | 3 | 5% | 5 | 6% |
| 07 | 3 | 5% | 0 | 0% |
| 08-11 | 0 | 0% | 9 | 11% |
| 12 | 1 | 2% | 0 | 0% |
| 13 | 2 | 3% | 2 | 2.5% |
| 14 | 3 | 5% | 4 | 5% |
| 15 | 1 | 2% | 6 | 7.5% |
| 16 | 6 | 10% | 3 | 4% |
| 17 | 1 | 2% | 3 | 4% |
| 18 | 2 | 3% | 6 | 7.5% |
| 19 | 1 | 2% | 6 | 7.5% |
| 20 | 0 | 0% | 3 | 4% |
| 21 | 2 | 3% | 3 | 4% |
| 22 | 6 | 10% | 4 | 5% |
| 23 | 4 | 7% | 2 | 2.5% |
Table 2: grouped by window — the pattern jumps out
| Window | Releases | Share | Commits | Share |
|---|---|---|---|---|
| Late night 22-02 | 23 | 38% | 19 | 24% |
| Early morning 03-07 | 18 | 30% | 16 | 20% |
| Morning-noon 08-12 | 1 | 2% | 9 | 11% |
| Afternoon 13-19 | 16 | 27% | 30 | 38% |
| Evening 20-21 | 2 | 3% | 6 | 7% |
Three findings:
First, upstream works in two burst windows, and the morning is dead quiet. The afternoon window (13-19) is where commits are densest (38%); late night 22-02 sees peaks in both commits and releases. From 8 AM to noon there is almost no activity — a classic afternoon-plus-late-night development routine.
Second, releases lag commits by 2-4 hours. Commits written late at night only get tagged as releases between 3 and 7 AM. So a 4 AM timer does catch the overnight burst — what it misses is the afternoon one: a commit pushed at 1 PM sits there until 4 AM the next day, up to 14 hours late. That’s exactly why my “auto-update” kept feeling one step behind.
Third, new versions arrive almost daily, but always in bursts. About 1.7 releases per day on average, with a record day of six, and occasional empty days. Seven commits fired off in one afternoon is the norm, not the exception.
Why “check every two hours” is the wrong answer
When an update feels late, the instinct is to shrink the interval — say, check every two hours. That’s wrong.
Upstream pushes in bursts: commits arrive one after another inside a single burst. The skip guard only asks “is there a new commit?”, it can’t tell “burst in progress” from “burst finished.” So a two-hour timer gets triggered three or four times within the same burst, and each trigger is a full rebuild — restart the proxy, recreate the manager container. That’s three or four interruptions in the middle of an afternoon. Nobody wants that.
High-frequency polling suits authors who ship at a steady drip. For burst-style authors, it just slices one burst into several rebuilds.
The right way: sit at the tail of each window, once per window
Two windows, two runs a day, each parked at the tail:
- 21:00 catches the afternoon window (the 13-20:30 wave): one rebuild swallows the whole burst, typical lag 1-4 hours, still before my own late-night working peak starts
- 04:00 catches the overnight window (22-02:30): it also dodges the cluster of other cron jobs around 3 AM
When there’s nothing new, a check costs about 310 milliseconds — fetch, compare commit, skip. I triggered the new schedule manually right after changing it: the script printed Already up to date... Skip build+recreate with 310 ms of CPU and zero restarts. Twice a day costs almost nothing; it only rebuilds when a version actually arrives.
A side lesson: auto-updates quietly eat disk
Before doubling the frequency, I took a habit glance at the backups: the update script backs up but never cleans up — 22 images tagged backup-before-update-<timestamp> were piled up in Docker, the oldest dating back to late July. Doubling the frequency would only speed up the accumulation.
Turns out CLIProxyAPI snapshots the old image before every update, but has no matching cleanup logic. I added a daily 3:50 AM cron that keeps only the three most recent backups. Every time you raise an update frequency, first check that the garbage it generates is being cleaned automatically — otherwise what doubles is your disk usage.
General takeaway
The fix for a lagging auto-update isn’t a shorter interval — it’s knowing when upstream actually ships. Burst-style authors (two windows a day, six or seven commits in a row) call for “window-tail” scheduling; only steady-drip authors suit fixed hours or high-frequency polling.
Set the alarm clock after you’ve read the other side’s routine.
