Every repository has a category of work that is genuinely worth doing and never quite worth doing now.
Documentation describing a function that was renamed. A configuration file referencing an environment that was decommissioned. A dependency four minor versions behind. Test coverage on the module everybody avoids. None of it is urgent, all of it compounds, and the backlog item for it has been open since March.
This is the class of work scheduled agents are actually good at — bounded, mechanical, low-consequence, and unblocked by the fact that nobody wants to do it.
The non-negotiable rules
Section titled “The non-negotiable rules”Before any of the useful parts, the four rules that make unattended automation acceptable.
1. It opens a pull request. It never pushes to a protected branch. No bypass rule for the agent identity, no exception “just for the maintenance workflow”. This is the rule everything else rests on.
2. Its permissions are minimal. Read the repository, write to its own branch, open a pull request.
Not contents: write on the default branch.
3. Its tool list is scoped to the job. A documentation agent does not need the package manager. A dependency updater does not need to edit source files outside what the update requires.
4. Somebody reviews the output. A scheduled agent producing pull requests nobody reads is a queue with a cron schedule.
What is worth scheduling
Section titled “What is worth scheduling”Dependency updates. Not the bump itself — Dependabot does that better and deterministically. The agent’s value is on the ones Dependabot opens and cannot finish: a major version with a breaking change that needs three call sites updated. Reading the changelog, making the mechanical change, running the tests.
Documentation drift. Comparing documentation against the code it describes and reporting where they disagree. High-value because nobody ever checks, and the drift is invisible until somebody follows an instruction that no longer works.
Dead code. Exported functions with no callers, feature flags whose branch is unreachable, configuration keys nothing reads. Detection can be deterministic; the judgement about whether removal is safe is not.
Test coverage gaps. Finding untested branches in stable code and writing tests for them. Bounded, and the output is easy to evaluate — a test either exercises the branch or it does not. Restrict it to code that has not changed recently, because coverage on stable behaviour is the case where the existing implementation is a reasonable specification.
Stale configuration. References to decommissioned environments, deprecated Actions versions, unpinned action SHAs, environment variables nothing reads. Each is individually harmless and collectively the reason a configuration file becomes something nobody wants to touch.
TODO and FIXME triage. Collecting them, checking whether the referenced issue still exists, and reporting the ones that have outlived their context. A TODO referencing a closed issue is either done or was abandoned, and both cases want the comment removed.
Link checking in documentation. Deterministic detection, with the agent useful for suggesting the current destination — a moved page usually has an obvious successor, and proposing it turns a list of broken links into a list of one-line fixes.
What is not worth scheduling
Section titled “What is not worth scheduling”Refactoring. An unattended agent restructuring code produces a large diff with no author to explain it. The review cost exceeds the value.
Formatting sweeps. A formatter does this deterministically. If your formatter is not running, fix that.
Anything touching .github/. Workflows, instruction files, agent definitions. A maintenance agent
editing the configuration that governs maintenance agents is a loop nobody wants to debug.
Security fixes applied automatically. Detection is automated; the fix for a vulnerability deserves a human who understands the exposure.
Anything with a destructive step. Migrations, deployments, infrastructure changes, deleting branches.
Anything on a schedule tighter than the review cadence. A nightly agent feeding a weekly review produces a backlog by construction, and the backlog is indistinguishable from the agent working well until somebody counts the open pull requests.
The shape of a maintenance workflow
Section titled “The shape of a maintenance workflow”name: Documentation drift check
on: schedule: - cron: '0 6 * * 1' workflow_dispatch:
permissions: contents: read issues: write
jobs: check: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v7
- name: Compare docs against code run: | copilot -p "Read docs/api/*.md and compare each documented endpoint against the route definitions in src/api/. Report only concrete disagreements: a documented endpoint that does not exist, a parameter that was renamed, a response field that was removed. Do not report style, tone or completeness. If you find nothing, output exactly: NO DRIFT FOUND." \ --allow-tool='shell(cat)' \ --allow-tool='shell(ls)' \ --allow-tool='shell(grep)' \ > /tmp/drift.md
- name: Open an issue if anything was found env: GH_TOKEN: ${{ github.token }} run: | if grep -q 'NO DRIFT FOUND' /tmp/drift.md; then echo "No drift reported." >> "$GITHUB_STEP_SUMMARY" exit 0 fi gh issue create \ --title "Documentation drift: $(date -u +%Y-%m-%d)" \ --body-file /tmp/drift.md \ --label documentationSix deliberate properties:
contents: read plus issues: write. It reads the repository and files a report. It cannot change
any code, which makes it the safest possible starting point — and issues: write is the narrowest
permission that lets it tell you what it found.
Read-only tools. cat, ls, grep. No editing, no shell beyond reading.
A weekly schedule. Matched to how often somebody will look.
workflow_dispatch as well. So you can run it on demand while tuning it.
A timeout. Agent steps hang.
An explicit “found nothing” output. Without it, an empty report and a failed run look the same, and the workflow silently stops being useful.
Start with the reporting version. An agent that opens issues describing what it found is useful, safe and easy to evaluate. Move to one that opens pull requests only once you have read a month of its reports and trust what it finds.
Writing the instruction for a maintenance run
Section titled “Writing the instruction for a maintenance run”The prompt in a scheduled workflow is doing a harder job than an interactive one, because nobody is there to say “no, not that”. Three properties separate the ones that produce useful reports from the ones that produce noise.
Name the category narrowly. “Check the documentation” produces observations about tone, completeness and structure — all defensible, none actionable. “Report documented endpoints that do not exist, parameters that were renamed, and response fields that were removed” produces a list somebody can work through.
Exclude explicitly. The exclusions do as much work as the inclusions:
Do not report:- Missing documentation for undocumented endpoints (separate concern).- Style, tone, formatting or ordering.- Anything under docs/archive/.- Endpoints marked @deprecated in the source.Every category you exclude is a category of report nobody would have acted on.
Require evidence for each finding. “For each disagreement, quote the documentation line and the source line that contradicts it.” A finding with both quoted is checkable in ten seconds. A finding asserting that something is wrong is a research task, and a report of twelve of those will not be read.
Give it a defined output for the empty case. Covered above and worth repeating because it is the difference between a workflow that quietly stops working and one that tells you it is fine.
Cap the report. “Report at most fifteen findings, most significant first.” An uncapped report on a repository with real drift produces eighty items and gets closed.
What this adds up to: the instruction is doing the same job as review instructions — encoding what this team considers worth reporting. The same test applies: could two engineers disagree about whether something matches this criterion? If yes, tighten it.
Escalating to pull requests
Section titled “Escalating to pull requests”When the reporting version has earned it, the change-making version follows the same rules with a narrower scope.
Add pull-requests: write, and contents: write scoped by branch protection. The agent pushes to
its own branch, which branch protection permits, and opens a pull request against a branch it cannot
write to directly.
One concern per pull request. A maintenance agent that fixes documentation drift and updates dependencies in the same pull request has produced something nobody can review cleanly.
Cap the size. An instruction to change at most ten files, or to stop after one category, keeps the output reviewable. A 200-file maintenance pull request will be approved without reading, which defeats the purpose.
Label it. maintenance and agent, so the population is visible.
Close it if it is stale. A maintenance pull request open for three weeks is out of date. Better to close it and let the next run produce a current one than to rebase something nobody was going to merge.
Reviewing maintenance pull requests
Section titled “Reviewing maintenance pull requests”The same order as any agent pull request, with two additions specific to this context.
Check it stayed in its category. A documentation agent that also changed a source file has escaped its scope, and that is worth investigating rather than just reverting.
Check the change is still wanted. A maintenance pull request from a weekly run may describe a problem somebody already fixed. Merging it blindly can revert their work.
Be willing to close without discussion. These are low-stakes changes. A maintenance pull request that does not look right costs nothing to close, and the next run will produce another. There is no author whose time you are wasting and no relationship to manage, which makes the closing threshold much lower than it would be for a colleague’s work — use that, because the alternative is a queue of maintenance pull requests you are half-reviewing out of politeness to a scheduled job.
Dead code: a worked category
Section titled “Dead code: a worked category”Worth walking through one category in full, because dead code shows the boundary between what to automate deterministically and what to leave as judgement.
The deterministic half. Finding candidates is a tooling problem: ts-prune, knip, vulture, a
coverage report showing zero-hit branches, a call-graph analysis. These produce a list, they produce it
reliably, and they should run first. An agent rediscovering unused exports by reading files is slower and
less complete than the tool that does it properly.
Where the tools stop. A list of unused exports contains four categories, and only one of them should be deleted:
Genuinely dead. Nothing references it, nothing will.
Referenced dynamically. Loaded by name, resolved at runtime, called through a registry. Static analysis cannot see the reference; deleting it breaks production at a time nobody will connect to the change.
Public API. Unused inside this repository, exported for consumers. Deleting it is a breaking change.
Recently added, not yet wired up. Somebody is mid-feature.
Where the agent earns its place. Sorting the list into those four categories, with evidence: does the
name appear in a string literal anywhere, is the module in the package’s exports, when was it added and
by which pull request. That is a research task across several signals, it is exactly what the tools do
not do, and it turns a hundred-item list into a shortlist of twelve worth a human’s attention.
What it must not do. Delete anything. The judgement that a symbol is genuinely dead is a judgement about the whole system, including consumers who are not in this repository, and being wrong is expensive in a way that is discovered late.
The output that works: an issue listing the candidates by category, with the evidence, and an explicit “the following need a human decision” section. Somebody spends twenty minutes on it once a month and the repository stops accumulating.
The generalisation: for every maintenance category, ask which half is detection and which half is judgement. Automate the detection deterministically, use the agent for the sorting and the evidence, and leave the decision with a person. That split holds for coverage gaps, stale configuration, TODO triage and dependency work equally.
Cost and cadence
Section titled “Cost and cadence”Match the schedule to the review cadence. Weekly is right for most maintenance categories. Nightly produces a queue.
Stagger different agents. Three maintenance workflows on the same morning produce three pull requests competing for the same attention.
Skip when nothing changed. A documentation drift check on a week with no code changes is a wasted run. Gate it on whether the relevant paths changed since the last run.
Watch the merge rate. If most maintenance pull requests are closed rather than merged, the agent is finding things that are not worth fixing. Narrow its instructions rather than continuing to generate them.
Untrusted input and scheduling
Section titled “Untrusted input and scheduling”A scheduled workflow has a security property worth stating: it is not triggered by anybody.
That matters. A workflow triggered by an issue comment can be aimed by whoever writes the comment. A workflow that runs at 06:00 on Mondays cannot.
Keep maintenance agents on schedule and workflow_dispatch. Not on issue_comment, not on
pull_request_target, not on anything an outsider can cause.
They still read repository content, which includes dependency source and any file a contributor added. The bounded-authority argument does the work: the agent opens a pull request, and a pull request is a thing your process inspects.
Rolling it out across repositories
Section titled “Rolling it out across repositories”One repository with a documentation drift check is an experiment. Forty repositories with the same check is a programme, and programmes fail differently.
The reusable workflow is the mechanism. A workflow_call workflow in a central repository, called by
a three-line file in each consuming repository. One definition to maintain, and a change reaches
everywhere — which is both the benefit and the risk.
Pin the version consuming repositories call. A reusable workflow referenced by branch changes behaviour in forty repositories with no pull request in any of them. Reference a tag, and let teams move deliberately.
Let repositories opt out. A maintenance check that does not fit a repository will produce noise there forever, and the team’s only recourse is to ignore it — which teaches them to ignore the useful ones too. An opt-out that is easy to exercise keeps the signal honest.
Let repositories tune the instruction. The exclusions that matter differ by codebase. A central workflow that reads a repository-local configuration file for its exclusions is portable; one with the exclusions baked in is portable to one repository.
Route the output to the owning team. An issue filed in a repository whose team did not ask for the check, with no assignee, is an issue nobody closes. Label it, and put it where the team’s existing triage will see it.
Measure adoption honestly. The question is not how many repositories run the check. It is how many have acted on a finding in the last quarter. A programme running everywhere and acted on nowhere is a cost with a dashboard.
Start with the repositories that asked. Two or three teams who want it, running it for a quarter, produces a version worth spreading. A simultaneous rollout to forty produces forty teams’ first impression of a workflow that has not been tuned yet, and first impressions of automated noise are durable.
Common mistakes
Section titled “Common mistakes”Write access to the default branch. The one that matters.
Nightly schedules with weekly reviews. A queue by construction.
Doing what Dependabot already does. Deterministic tooling first.
Letting a maintenance agent touch .github/. It edits the configuration that governs itself.
Unbounded pull request size. A 200-file diff gets approved unread.
No “found nothing” output. Silent failure looks identical to a clean result.
Triggering on untrusted events. A schedule cannot be aimed; an issue comment can.
Merging without checking the change is still wanted. A stale maintenance pull request can revert a recent fix.
Never checking the merge rate. An agent generating unwanted changes will keep generating them.
Knowing when to turn one off
Section titled “Knowing when to turn one off”Maintenance workflows outlive their usefulness silently, because a workflow producing nothing looks identical to a workflow finding nothing.
The quarterly questions, per agent:
Has anybody acted on a finding? Not read one — acted. If the answer is no for two quarters, the category is either already clean or not worth reporting on.
Is it still finding things? A drift check on a repository where documentation and code are now reviewed together should report less over time. Falling to zero is success, and success means you can reduce the cadence or stop.
Did the underlying problem get solved elsewhere? A TODO triage agent becomes redundant when the team adopts a convention requiring an issue link on every TODO. The convention is better than the check, and keeping both is pure cost.
Is it producing findings that get closed? A high close rate means the instruction is wrong. Tighten the exclusions or turn it off; do not leave it generating things nobody wants.
Would anybody notice if it stopped? The honest test. If the answer is no, delete the workflow file.
Delete rather than disable. A commented-out workflow is a workflow somebody will re-enable in two years without knowing why it was stopped. The git history is the record.
The healthy end state for most repositories is one or two maintenance agents that consistently find things worth fixing, rather than six that run reliably and are read by nobody.
Mental model
Section titled “Mental model”A conscientious contractor who comes in on Mondays and leaves a list.
They notice the things nobody has time to notice. They do not have the standing to change anything directly, and they do not need it — the list is the value. When they have earned enough trust, they start leaving small, clearly-labelled proposals rather than just observations.
They never have the keys to production, never work on anything urgent, and if they stopped coming in for a month nothing would break. That last property is the test of whether you have configured this correctly.
What you learned
Section titled “What you learned”- Maintenance agents open pull requests and never write to a protected branch — no exceptions
- Start with the read-only reporting version and escalate only after a month of useful output
- Good categories are mechanical and low-consequence: documentation drift, dead code, coverage, stale configuration
- Deterministic tooling first — do not rebuild Dependabot or your formatter
- Never let a maintenance agent touch
.github/ - Cap pull request size and keep one concern per pull request
- Schedule-triggered workflows cannot be aimed by an outsider; comment-triggered ones can
- Match the cadence to your review capacity and watch the merge rate
Exercise
Section titled “Exercise”Use a disposable repository. No production credentials.
-
Add a weekly scheduled workflow with
contents: readthat reports documentation drift as an issue. Trigger it manually. -
Introduce a real drift — rename a documented function. Run it again. Predict: does it find it?
-
Remove the explicit “NO DRIFT FOUND” instruction. Run it on a clean repository. Predict: can you tell the difference between “nothing found” and “the step failed”?
-
Escalate it to open a pull request. Confirm branch protection blocks a direct push to the default branch.
-
Remove the size cap and run it on a repository with substantial drift. Predict: would you read the resulting diff?
-
Change the trigger to
issue_commentand consider who can now cause it to run. Change it back. -
Delete the repository.