26. September 2026
A cron line looks like a latency setting. 0 * * * * reads as “check every hour, so nothing waits long.” In a private repository it’s also a purchase order, and it reorders every hour whether or not there’s anything to buy.
In July I wrote about running a personal GitHub account as a control plane: configuration as code, scheduled workflows, a deliberately non-admin GitHub App, and a bounded role for the model. This post follows up on part of that design, the scheduled part, after it did something I hadn’t priced in. On 26 September my account’s GitHub Actions budget was $30 over. Development CI didn’t overrun it. The automation meant to make the portfolio cheaper to maintain did.
The project is auto-managed, a private repository that maintains about 86 of my other repositories. It runs self-hosted Renovate for dependency versions and keeps Dependabot for security PRs. It has a GitHub App that Renovate authenticates as, so its PRs are authored by auto-managed[bot]. A workflow called pr-lifecycle sweeps the portfolio and merges green dependency PRs, and a Claude-driven fix pass tries to repair the red ones. Because the repository is private, every run of every one of those workflows is billed Actions time.
I measured the previous 30 days across all my private repos and split the minutes by what triggered them. The split, in Linux-equivalent minutes:
So automation came to about 5,600 minutes, and that alone was more than the plan’s included quota. The consequence is easy to miss. Development CI didn’t cause the overage. It became the overage, because by the time I pushed anything the bots had already spent the allowance, and every development run was billed at overage rates.
That’s worth saying plainly, because it cuts against the story I’d told myself about this project. The goal I wrote down when I started auto-managed was lower-cost maintenance of the portfolio. In money terms, the maintenance machinery now cost more than the development it was maintaining.
The largest single line item was the merge sweep. pr-lifecycle ran hourly. Over the 30 days it ran 739 times and billed 1,432 minutes.
Most of those runs had nothing to merge. The average run took about 81 seconds, which is roughly what it takes to walk 86 repositories, list their open PRs and find none that are ready. GitHub bills per job, rounded up to the next whole minute, so an 81-second job bills as two minutes. Rounding alone added about half again on top of the time actually used, and none of those minutes merged anything.
This is the mechanism I’d underline for anyone running scheduled workflows in private repos. The cost of a scheduled workflow is set by how often it wakes up, not by how much work it finds. A no-op run isn’t free because it did nothing. It costs the rounded-up minimum for every job in it, every time. Hourly works out to about 720 wake-ups a month, and at two billed minutes each that’s roughly 1,440 minutes before the sweep has merged a single PR. My measured 1,432 is almost exactly that figure, which suggests the sweep’s cost was nearly all wake-up and hardly any work.
The workflow comment explained why it was hourly. An earlier version had run daily, and the reasoning recorded then was that a daily sweep left green PRs sitting for up to a day. That’s a latency argument, and as a latency argument it’s fine. Nobody costed it, because a cron expression doesn’t look like something that costs money.
Hourly also caused a problem that wasn’t about money. The sweep used cancel-in-progress: true, so when I dispatched a manual run near the top of the hour, the manual run and the cron cancelled each other part-way through. One run covered 18 of the 86 repos and still reported success. I’ve written before about tools that answer confidently when they shouldn’t, and this is the same failure in a workflow: a partial pass that looks like a whole one. Running often didn’t just cost more, it also made each run less trustworthy.
Renovate itself cost 664 minutes over the 30 days, running daily. That’s real but not dramatic. What Renovate does is the expensive part.
Each Renovate run opens new PRs and rebases existing ones across the portfolio, and every one of those PRs lands in a private repository with a pull_request CI workflow. Every rebase triggers a full CI run. That’s where the 3,200 bot-triggered minutes came from: not Renovate’s runtime but the CI it wakes up in 86 other places. The scheduler’s schedule controls a cost that doesn’t appear on the scheduler’s own bill.
That changes how the cadence question should be asked. “How often should Renovate run?” sounds like “how fresh do I want my dependency versions?”, and for a personal portfolio the honest answer to that is “not very.” But the question the bill is actually asking is how many CI runs across the portfolio I’m willing to pay for per week, per repo, to learn about a minor version bump. Framed that way, daily was hard to defend.
There’s also a multiplier I haven’t turned down yet. By default Renovate rebases a PR whenever its base branch moves, not only when the PR has a conflict. In an active repo, each push to main can re-run CI on every open dependency PR. Setting rebaseWhen: conflicted should cut that, and it’s on the follow-up list, not done.
The second reason to slow the sweep down is that it was meant to be mostly redundant. Renovate is configured with platformAutomerge: true, which asks GitHub to enable native auto-merge on each PR. With that on, GitHub merges the PR itself when checks go green. That’s the right design: an event (checks completed) triggers the merge, and nothing has to poll. On that reading, the sweep only needs to catch what native auto-merge can’t, meaning Dependabot PRs and repos without auto-merge enabled, and a backstop doesn’t need to run hourly.
I want to be careful here, because my own notes disagree with each other. In August, while working out why auto-managed had never merged anything, I found that GitHub only accepts an auto-merge request when a PR is blocked on something. A repo whose branch protection requires no approvals and names no required status checks leaves a green PR already mergeable, so GitHub refuses to enable auto-merge on it. At that point only 14 of 118 open PRs had auto-merge enabled, and I concluded that the sweep’s own script had to be the real merge engine. The September decision assumes native auto-merge now carries most of the load. Since then ensure-ci has been adding CI gates to repos that had none (more on that below), which may have changed the picture, but I haven’t re-counted how many dependency PRs have auto-merge enabled today [unverified: current share of Renovate PRs with native auto-merge actually enabled].
That uncertainty doesn’t weaken the argument so much as show where it’s fragile. Event-driven merging is cheaper than polling only if the event exists. A PR with no required check has nothing to wait on, so there’s no event to trigger a merge, and the choice left is between polling and doing nothing. Getting the cheap design depends on something that looks unrelated to cost: every repo needs a required CI check.
The fix, in commit b3c6436, is almost entirely a schedule change:
pr-lifecycle merge sweep went from hourly to every six hoursMy estimate is that auto-managed’s own spend drops from about 2,400 minutes a month to about 600. You can check the sweep’s share with the same arithmetic as before: 120 wake-ups a month at two billed minutes is about 240 minutes, against about 1,440. The downstream reduction, meaning the bot-triggered CI in other repos, should be the bigger saving, since Renovate now opens and rebases PRs two days a week instead of seven. I expect it but haven’t measured it. The honest status is “re-measure after a full month.”
Label sync is the one change that swapped polling for an event rather than just polling less. It used to run on a schedule to catch drift. Now it runs when the label definitions change, plus a monthly pass to catch drift made by hand in the GitHub UI. Most of the value comes from the push trigger, and the schedule stays only for the part that has no event.
Here’s the claim I’d generalise from this, with the caveat that it rests on one portfolio and one bad month: automation in a private repo should be designed around the cost of each wake-up, not the latency of the result.
In practice that comes down to a small number of moves, in roughly this order of preference.
Trigger on the event if there is one. A merge should happen because checks completed, not because a clock ticked and something went looking. A label sync should happen because the label file changed. Most “check every N minutes” workflows are standing in for an event that exists but wasn’t wired up. Sometimes, as with auto-merge on unprotected branches, the event only exists once some unrelated configuration is fixed first.
When you have to poll, coalesce. A sweep that walks 86 repos every six hours does the same useful work as one that walks them every hour, as long as the work is mostly empty. Coalescing the empty hours into one pass costs a sixth as much. A green PR waiting six hours for a backstop merge isn’t a real cost in a personal portfolio. A dependency PR isn’t an incident.
Count the downstream fan-out, not the scheduler’s runtime. The workflow you schedule is often the cheap part. What costs money is what it triggers: PRs opened, branches rebased, CI fired in repositories whose bills you don’t look at when you edit a cron line. Renovate’s 664 minutes against the 3,200 it caused elsewhere is a ratio of roughly one to five, and I suspect that ratio is typical for any bot that opens PRs.
Remember the rounding floor. Per-job minute rounding means splitting a no-op workflow into several small jobs costs more, not less. A fast check that decides whether there’s any work to do is a good idea, but in Actions billing terms it should be a step in the one job, not a job of its own.
None of this is new. It’s the same trade-off as polling versus interrupts, pull versus push, cron versus webhooks. What’s new is how cheap it’s become to add a scheduled workflow, and who’s adding them.
auto-managed was built mostly with a coding agent, and its workflows include a Claude-driven fix pass that runs on a schedule. Both of those matter here.
Writing a scheduled workflow now costs close to nothing to author. You describe a maintenance chore, the agent writes the YAML, and the cron expression it picks is a reasonable-sounding latency default. Nothing in that loop surfaces cost. The agent doesn’t see the bill, the diff doesn’t show minutes, and the workflow succeeds, so from every signal available at authoring time the change was correct. Over the two months since July, auto-managed has gained more scheduled jobs than I would ever have written by hand, and each was reasonable on its own terms. No single one caused the overage. Together they did.
The fix pass sharpens this. An agent-driven job does more work per run than a script does, and it can be billed twice: once for the Actions minutes and once for the model call. Moving it from daily to twice a week was the obvious move once I looked. The point is that I hadn’t looked, because when that job was written, its cadence was the least interesting line in the file.
So the change I’m making in how I write these, rather than just in this one repo, is to treat the schedule line as the one that needs a justification. It gets an estimate next to it: wake-ups per month, billed minutes per wake-up, and what it fires downstream. If I can’t write that sentence, the cadence is a guess, and at hourly in a private repo a guess can cost more than development CI.
A few caveats, because the numbers above are estimates and the post shouldn’t sound more certain than the data is.
GitHub’s per-run timing endpoint, /actions/runs/{id}/timing, returned zero for every run I asked about. The billed minutes above are reconstructed from job start and end times, rounded up per job, with macOS minutes weighted ×10 and Windows ×2 to get Linux equivalents. Dependabot’s own update jobs and jobs that never got a runner are excluded. So the figures are careful approximations of the bill, not the bill itself. The full measurement is in docs/reports/2026-09-26-actions-cost-review.md in the auto-managed repo.
The 2,400 → 600 figure is an estimate, and the downstream saving is a prediction. I’ll know in a month.
And the review turned up things that aren’t about cadence at all. The largest share of development spend comes from macOS jobs in four repos (draws, drive, insight and emx) that run on every push rather than just on main and tags. At a ×10 multiplier, that’s the next cut to make, and it’s a trigger-scope problem, not a schedule problem. One repo, building-with-bazel, has CI pinned to the retired ubuntu-20.04 runner, so it never runs at all. That’s the cheapest CI in the portfolio, and also the least useful.
The bill made the wasted minutes visible, and it only did that because I went over budget. Without that, the hourly sweep would have gone on quietly reporting success, merging nothing most hours and spending minutes every time. A schedule that wakes up to find nothing looks, from every signal the workflow gives you, like a schedule that’s working.