Agent Change Process
Note: This article was drafted by AI and reviewed by Erich. I plan to rewrite all explanatory content in my own words - these serve as placeholders to establish the documentation structure.
How to execute infrastructure changes, especially when working with AI agents that may lose context across sessions.
How changes reach main
There are two routes, and which one applies is mostly decided by who you are rather than how big the change is:
| Route | When | Changelog fragment |
|---|---|---|
| Direct to main | An interactive human session making a small, fix-forward-safe change | Orphan prefix: +<slug>.<type>.md |
| Feature branch + PR | Everything larger, and all remote-agent work | Branch name: <branch>.<type>.md |
Remote agents have no choice in the matter: the talos bot is read-only on canonical, so a direct commit to main is not something it can do. Branch off upstream/main, push to origin, open a cross-repo PR. See AGENTS.md for the authoritative statement of both rules.
Multi-phase work that spans sessions is still one branch and one PR — sequence it however the work wants, and lean on the PR to carry state between sessions.
Context loading: start by finding and reading the docs relevant to the change area — grep docs/ and follow wiki-links. For problems with a very large surface area, mise run ai-sources concatenates all non-doc source files (~600K tokens); confirm with the user before loading it wholesale.
Retired: this document used to open with a C0/C1/C2 change classification assigned before work began, and devoted most of its length to the Mikado Branch Invariant — a commit-ordering discipline for C2 chains, enforced by a
commit-msghook. Both are gone: AGENTS.md replaced the classification with the two-route split above, and the Mikado apparatus (the hook,mikado-branch-invariant-check,docs-mikado, themikado-navigatorsubagent, and theC2(<chain>):commit convention) was removed once no chain had used it in a long time. Old cards and commits referencing them are historical.
Feature branch + PR
The default route for anything non-trivial, and the only route available to remote agents.
Process
- Find and read the docs relevant to the change area
- Search related docs — read existing documentation and reference cards related to the change area
- Create a feature branch and open a PR early (draft is fine)
- Plan first (issue-driven cycles) — on a non-trivial issue, the agent’s first cycle posts a plan comment instead of a PR: the brief as understood, scope in and explicitly out, ordered steps each mapped to the PR that will land them, verification per step, and open questions. It is the cycle’s terminal comment, and the cycle stops at “waiting for a go on this plan”. The human’s reply is the verdict: a revision revises the plan and stops; an approval proceeds to the plan’s first step. A
!!plan: skipline anywhere in the issue body opts trivial issues out of planning; the bareplan: skipform is retired — do not use it in new posts. The talos README Pragmas section is authoritative for the full pragma list (including!!noreply/!!bump, which suppress a turn entirely). It is the headless form of the rule below — the first externally visible artifact should be cheap to argue about, not a diff - Documentation first — commit doc changes reflecting the desired end state before writing code. This helps the reviewer understand intent and catches design issues early
- Implement — commit code changes, pushing as you go. The PR gets updated along the way and the user can review and comment at any point
- Add changelog fragment —
docs/changelog.d/<branch>.<type>.mdfor any user-visible or noteworthy changes - If the PR changed
containers/: the PR’s build check validates the nix build (hash-TOFU rounds run as the check and post failures as PR comments); on merge, CI rebuilds at the merge commit, pushes the image to zot, and the horkos publisher opens the kustomization pin PR — no post-merge manual rebuild — see build-container-image > Container tags and merge strategy - Deploy from the branch — do not wait for merge:
- ArgoCD:
argocd app set <service> --revision <full-40-char-sha> && argocd app sync <service>. Pass a SHA, never a branch name: workload apps sync automatically, so a branch revision would make every later push to that branch deploy itself unreviewed. TheArgoCD Deployworkflow enforces SHA-or-main; hand-run commands should match it (see argocd > Deploying from a branch) - Ansible: run playbooks directly from the branch checkout
- Workflows: point workflow triggers at the branch if needed
- ArgoCD:
- Approve the CI checks. An agent PR’s checks sit
pendingwith no run until a human clicks Approve and run in the forge. This is deliberate — see §Why agent PRs need an approval click - After user review and successful deployment, the user merges the PR
- After merge: reset any overridden revision with
argocd app set <service> --revision main. Apps still trackingmainneed nothing — the merge deploys itself
How the PR reaches a human
.forgejo/CODEOWNERS holds one catch-all rule, so opening a PR requests review from eblume and the PR lands in his Review requests filter. Without it an agent PR carries no assignee and no reviewer, and so appears in no queue at all — owning the repo is not a filter. Every agent repo carries the same file.
This is a notification, not a gate: no branch protection is involved and nothing about it can block a merge. Forgejo reads the file from main, fires only when the PR opens, skips drafts, and never asks the PR’s own author — so a PR the user opens himself stays quiet. Do not also pass tea pr create --assignees; the review request is the queue that gets watched.
The pattern is .*, not GitHub’s *. Forgejo compiles each one as a Go regexp anchored ^…$, and ^*$ does not compile — the rule is dropped with only a warning, leaving a file that reads correctly and does nothing.
Build artifacts
Container images in the registry are independent of branch lifecycle — a branch reset or a rebase does not invalidate them:
- Registry images are build outputs cached in zot — tagged with commit SHAs, so each build is unique and traceable
- The tag’s SHA is the merge commit. The post-merge build runs at main’s new head, so the tag is deterministic from the merge itself — no branch-head dance, no
[branch]/[main]flip to babysit. The horkos publisher pins the tag when the merge run pushes it;mise run container-list <name>confirms it before the pin PR lands. The PR build check validates the nix expressions without producing the published image. See build-container-image > Container tags and merge strategy - Builds run on merge (plus the PR build check) — no dispatch, no warrant; the horkos publisher opens the kustomization pin PR when the merge run pushes the tag
- If a build succeeds but deployment fails, the image is fine; the problem is elsewhere. Document what you learned and try again
- If a build fails in CI, no image is pushed. Fix the nix/dockerfile and merge the fix — the push to main rebuilds and pushes
Why agent PRs need an approval click
Open an agent PR and its checks show pending with no workflow run behind
them. Nothing is broken and nothing is queued: the forge is waiting for a
human to press Approve and run. Press it and the run is created, executes,
and reports normally.
The cause is the fence working. The talos bot is deliberately read-only on
canonical, which makes its PRs outside contributions, and Forgejo withholds
workflow runs on outside contributions until someone with write access
approves them.
The click decides which workflow files run — not merely whether they run. That is the part worth remembering, because it is not what the button looks like it does:
| Author | Which workflows execute |
|---|---|
| Untrusted | ”the workflows found in the target branch of the pull request will be used instead of those found in the pull request” |
| Trusted (Approve always) | “workflows found in the pull request content are used … taking into account any changes done to these files as part of that pull request” |
So trusting the bot permanently would mean an agent PR that adds a workflow with
on: pull_request runs that file, on the indri runner, at PR-open time —
before a human has read the diff. That is a path from “an agent wrote a file” to
“code ran on a homelab host” with no human in between, and it undoes
warrant-approval-gated-runs invariant 3 (whose markers include definitions run from main only)
one layer down, in forge settings rather than in the repo.
Approve always was set for these workflows on 2026-08-07 and revoked the same day for exactly that reason. Trust is per-repo per-contributor and expires after three months idle, so a stale grant is not forever — but it should not be granted to an agent identity at all.
What the click does not protect
Worth knowing so the click is not mistaken for more than it is:
-
Secrets were never at risk. Forgejo does not pass secrets to
pull_requestruns from forks, trusted author or not. The blumeops vault and the Actions secrets stay out of reach on this path. -
Deploys were never at risk. Privileged workflows are
workflow_dispatchonly onruns-on: priv; a pull request cannot trigger them at all. Warrant still gates every one.One marker has a documented exemption: auto-apply actions may additionally be triggered by
on: pushto main with apaths:filter — running main’s reviewed definition on their dedicated priv runner label, applying only the push’s own SHA, never an arbitrary input. No warrant is minted for them: the merge-commit gate is the gate, and it refuses anything that is not the merge commit of a merged PR, so a pull request still cannot trigger a privileged workflow at all. -
Strangers are not the threat. The instance has registration disabled, so nobody can fork this repo without an account Erich created. The threat model is prompt injection in an agent session — the case agent-containerization exists for.
Do not reach for pull_request_target
The obvious way to skip the click is pull_request_target, which runs the
definition from main and is not subject to the approval gate. Resist it.
It is the one trigger that receives repository secrets and a write-capable
token, and it is the basis of the “pwn request” vulnerability class: the
workflow is trusted, so the moment it checks out and executes the PR’s code,
the PR author has both.
It can be written safely — check out main for the validators, check out the PR
head into a subdirectory as data only, never execute a line of it — but that
discipline then has to survive every future edit by every future agent, and the
failure mode is far worse than the thing being avoided. For a check whose job is
catching a forgotten changelog fragment, it is a bad trade. There are no
pull_request_target workflows in this repo; adding one is a much larger
decision than its diff will look.
The residual problem
A PR where nobody clicked shows no check rather than a failing one, which
reads the same as “nothing to validate”. The fix is to make the gate statuses
required in branch protection on main, so an unrun check blocks the
merge instead of looking neutral — not to remove the gate.
Branch protection on main is declared in forge/branch-protections.json
and reconciled by mise run forge-reconcile — CI checks it for drift,
but only a human run applies it (see the script’s docstring). It requires
the gate contexts by name — Docs Checks, Image Pins, Tests, and the four
Lint jobs — plus the glob * (pull_request). Forgejo resolves a glob to the
worst state among every context it matches, so the glob makes any PR
check that actually ran blocking, path-filtered ones included (the flake
checks, Forge Reconcile), while a path-filtered workflow that did not
trigger posts no context and blocks nothing. The named contexts stay
because the glob cannot tell “nothing ran yet” from “nothing to run”: on an
agent PR nobody has approved, they are what holds the merge.
A context string is workflow name / job name (event), so renaming a
gate job silently un-requires it by name (the glob still blocks on its
failure, but no longer on its absence): update the protection file whenever
a job in docs-checks.yaml, image-pins.yaml, lint.yaml or tests.yaml is renamed.
A push-side version of the trap: the automatic Actions token can never be
push-whitelisted (Forgejo #11159),
so adding a push whitelist to a repo’s branch protection requires
checking that repo’s workflows for pushes to the protected branch. A
release workflow that commits back to main (changelog, version bump)
fails on its next run with pre-receive hook declined, and the fix is a
whitelisted-PAT push — the unprotected_file_patterns exemption does not
apply to the Actions token either (create-release-artifact-workflow).
Forge Reconcile’s PR check: intended vs unexpected drift
Forge Reconcile is the only workflow that both reads and writes desired
state, and the one whose PR check can be wrong in a subtle way: it reconciles
against repos.json / forge/branch-protections.json, so a PR that edits those
files is itself the pending drift. A naive --check that fails on any drift
would sit red until a human applies the change from the branch — and since #1370
made every PR check blocking, that is a merge-blocker for the very PR that made
the change.
So on a same-repo (eblume-owned) PR the check compares the live forge against both the PR’s declared state and main’s declared state, and reports in four categories:
- Intended — auto-applies on merge (a
repos.jsongrant/hook/label delta the CI token can apply) → warn only, posted as a PR comment. Goes live via the merge push. No apply-before-merge step. - Intended — needs a human apply (protection / hook-create deltas the CI
token can’t apply) → warn + comment: run
mise run forge-reconcilefrom gilbert after merge. - Unexpected live drift (live forge vs main-declared, on state this PR does not change) → fail, named as “a merged change awaiting a human apply, or the forge drifted.” This is the one that turns an unrelated same-repo PR red while a merged protection/hook change waits on a human apply — a known, documented trade-off, surfaced as its own category so the red reads as pending apply, not this PR broke something.
- Invariant violations and unreadable repos (site-admin,
PINNED_READ_ONLY, the bot-whitelist fence, a malformed policy, a repo the token cannot read) → always fail.
Fork / agent PRs skip. They carry no FORGE_REPO_WRITE_TOKEN, so the check
falls through to --skip-if-no-token and exits 0 (a green tick that verified
nothing is worse than no check — the same reason horkos-forge-drift had no
pull_request trigger). The new semantics therefore run only on same-repo PRs.
The intended-drift report is posted as a plain, non-triggering PR comment
(never a fix-cycle review), so a warn-only result — notably a revocation — is
still read. See mise-tasks/forge-reconcile (—pr-base-ref) and the workflow.
CI failure notices
A PR where a check ran and failed still hides its reason behind runner
logs, which agent sessions cannot read (action logs are private to the forge
UI). The PR-facing check jobs therefore end with the shared
.forgejo/actions/report-failure step — Lint’s four jobs, Docs Checks / checks, Image Pins / checks, Tests / horkos-test, Build Container’s build job, and Forge Reconcile’s reconcile job; the only pull_request jobs without it are the path-filtered flake-check builds and Build Container’s trivial detect job.
On failure it posts the tail of the job’s own teed log to the PR as
forgejo-actions, with the run number and a
copy-paste mise run runner-logs <run> -j <N> pointer (matrix jobs omit the
pointer — the jobs API cannot disambiguate legs). One notice per (workflow,
job, matrix leg, head SHA) — a re-run of the same head does not double-post.
Forge Reconcile additionally posts, on the warn-only PR-check path, a plain
intended-drift notice (.forgejo/actions/report-pr-notice) so a green
same-repo PR whose changes land on merge is still read — see
Forge Reconcile’s PR check.
On a PR authored by talos, the notice is a review (event COMMENT),
which the talos forge loop picks up like any review and starts a fix cycle
with no human in the loop. The review body is filtered build output — text
produced by third-party dependencies — and talos is prompted to treat it as
evidence of a failure to diagnose, not instructions to follow. The loop is
capped at three failure reviews per PR: the next failure then posts one
plain, non-triggering “a human should look” comment, and later failures stay
silent. PRs by anyone else get a plain comment — visible, but never
triggering a cycle.
Git discipline
- Direct to main: interactive human sessions only, small fix-forward-safe changes
- Feature branch + PR: single branch, PR early, push often. The only route for remote agents
- Changelog fragments (always): add
docs/changelog.d/<name>.<type>.mdfor any user-visible or noteworthy change. Direct-to-main uses orphan fragments (+<descriptive-slug>.<type>.md) to avoidmain.*collisions and includes the fragment in the same commit; branch work uses the branch name (<branch>.<type>.md) and adds it during the branch. - Deploy from branches — branch work deploys from the unmerged branch (ArgoCD
--revision, Ansible from checkout, etc.). Reset to main after merge. - GitOps requires pushing to test — if a pushed commit breaks, revert it promptly
Related
- ai-assistance-guide — General AI agent conventions
- exploring-the-docs — Documentation structure overview