Skip to content

migrate-config ​

Migrate a cli-config.yaml to the current schema version. Deterministic, idempotent, and previewable.

bash
the-loop migrate-config [--path ~/.the-loop/cli-config.yaml] [--dry-run]

Why it exists ​

Some changes are breaking, and the owner's call was to make them breaking properly rather than carry a shadow override forever — "Let's make breaking changes. /upgrade should be able to handle it."

A breaking change is only as good as its migration, so four properties hold:

  1. Version the schema; do not sniff for keys. Detection is exact.
  2. Fail closed and loudly. A config still carrying a removed key makes the runtime refuse to start, naming the key, its replacement and the exact command. Silently ignoring a value you deliberately set would change your behaviour without telling you — much worse than an error.
  3. The migration is a deterministic key move, idempotent and previewable, that reports what it changed rather than rewriting the file quietly.
  4. It is tested both ways — an old config migrates to the expected new one, and the runtime refuses an un-migrated one.

What it migrates today ​

Current version: 0.12.0.

The integrations.jira stub → the Jira block (issue-475, decision-142). jira.transport and jira.cli are removed: the daemon reaches Jira through the pycontribs jira SDK and runs no jira binary. A string jira.api.tokenEnv becomes a one-item list, and a legacy jira.api.baseUrl becomes a bare jira.site with deployment: cloud. The stub never named the account email's variable or the project → repository map, and no migration can invent them, so the report asks for jira.api.emailEnv and jira.projects. A stub with nothing left in it is removed.

routing.autoExecuteLabel → routing.autoExecuteLabels, and polling.sources[].label → polling.sources[].labels (issue-381, decision-131). The arming label is a list now, every entry of which an issue or PR must carry, so an instance sharing a repository with others can add a label of its own and stop arming everybody's items. The move is lossless — your one label becomes a one-entry list that arms exactly what it did — so it is done for you: the string is wrapped, an empty source label is dropped (absent already means reuse the routing list), and a list already declared beside the old key is kept with a note. An empty autoExecuteLabel becomes [], which the schema refuses; the report says to declare at least one label. The old keys are refused rather than read, because reading the shared default in place of a label you set would arm more than you asked for.

routing.graph.repoHooks removed (issue-352, decision-123). The CLI reads no repository's harness config any more, so there is nothing of a repository's to refuse: the switch that refused a repository's graph.hooks goes, and the declaration itself is now yours, under routing.graph.hooks. The critic roster moved the same way — from the harness config's reviews.critics[] to the top-level critics[] here. Nothing inside this file is converted; the report says where to copy the entries you want from the repositories you operate, and what you do not copy does not run.

polling.sources[].repos → the top-level repositories (issue-348). The list of repositories an instance works with was named after the one ingress that read it, so the gh-webhook receiver was bounded by no repository list at all. It is a top-level repositories now, read by every ingress. Every github source's list moves up, in declaration order, deduplicated, and channels.slack.kickoff.repo joins it — that key still names the Slack channel's default target, but it now points at a declared repository rather than declaring one. A source under another provider keeps its own repos untouched. The report says the one thing that changes behaviour: your receiver is now bounded by this list too, so a repository you were relying on it accepting without ever listing has to be added.

channels.slack.events → channels.slack.subscribe, and channels.slack.authorizedUsers → routing.authorizedUsers (issue-309). A channel now subscribes to events and may publish them (channels.slack.publish), so one word cannot name both directions; and identity is declared once — each routing.authorizedUsers entry is a person with their id on every channel. The rename is verbatim. The move cannot pair a Slack member id with a GitHub login on its own, so each id becomes its own {slack: U…} entry and the report asks you to fold it into that person's GitHub entry (a github: line and a slack: line on one entry); the member stays authorized on Slack meanwhile.

collaborators and notifications removed (issue-304). Both were declarable and neither was ever read: the daemon events the filter named (work-item-spawned, dispatch-failed, session-died, event-dropped-unauthorized) are raised by no code, so an operator who filled them in configured nothing and was never told. There is nothing to convert — a role list that resolved to no recipient has no equivalent — so both blocks are removed and, if you had actually filled one in, the report points at channels.slack: one bot for the whole daemon, subscribed to the events you want by name. Identity stayed in routing.authorizedUsers and channels.slack.authorizedUsers — until issue-309 folded the second into the first.

integrations.slack removed (issue-245). Slack converged on the channels layer — one channels.slack section configures the bot that posts notifications and carries replies back into the session. A webhook URL cannot become a bot token, so the migration removes the key and the report says what to configure instead; nothing is converted silently.

ghBinary → integrations.github.cli.binary (issue-109). The per-feature keys — declared separately under routing.control, routing.reactions and routing.announce — move to a single integrations.github.cli.binary. Three copies of one setting is exactly the duplication the integrations block exists to remove.

polling.stateFile removed (issue-128). The poller's ledger became one record per work item under state.root, so a file path has nothing left to point at.

webhooks.ghWebhook.routing → routing (issue-142). The block is promoted to the top level, unchanged key for key. It was never the receiver's: the poller reads every value in it verbatim for dispatch, and the-loop sessions reads it again. If your config happens to declare both the old and the new block, the top-level one wins key by key and the report names every value it dropped — never a merge, because unioning two authorizedUsers lists would quietly re-admit a login you had removed.

text
$ the-loop migrate-config --dry-run
migrated the CLI config:
  · webhooks.ghWebhook.routing.control.ghBinary → integrations.github.cli.binary ('gh')
  · webhooks.ghWebhook.routing.reactions.ghBinary → integrations.github.cli.binary ('gh')
  · webhooks.ghWebhook.routing.announce.ghBinary → integrations.github.cli.binary ('gh')
  · webhooks.ghWebhook.routing → routing (top level; it governs the poller too)
  · collaborators removed — nothing read it (issue-304); Slack is declared once under `channels.slack`
  · notifications removed — nothing read it (issue-304); Slack is declared once under `channels.slack`
  · channels.slack.events → channels.slack.subscribe (a channel subscribes AND publishes now, issue-309)
  · channels.slack.authorizedUsers → routing.authorizedUsers (1 Slack member id(s), one `{slack: …}` entry each — identity is declared once, issue-309)
  · version '0.1.0' → '0.10.0'

--- /home/you/.the-loop/cli-config.yaml (preview, not written) ---
version: 0.10.0
…

Flags ​

FlagDefaultMeaning
--paththe resolved CLI configWhich file to migrate.
--dry-runoffPrint the report and the migrated YAML without writing.

--path defaults to whatever the normal resolution order finds.

Behaviour ​

  • Reads with the raw loader, on purpose. The normal loader refuses an un-migrated config — and refusing to load the very file you are trying to migrate would be a locked door with the key inside.
  • Keeps a backup. The pre-migration file is written to <path>.bak before the new one replaces it. A breaking migration you cannot walk back from is a worse trade than one stray .bak.
  • Idempotent. Running it twice produces the same file and reports no second change: config is already current; nothing to migrate.
  • Reports, then acts. The report is printed either way; --dry-run stops there.

Exit codes ​

CodeMeaning
0Migrated, or already current
2File not found, or it could not be parsed

When the runtime refuses ​

text
this CLI config still declares `webhooks.ghWebhook.routing`. That block governs BOTH
ingresses — the poller reads it verbatim for dispatch — so it moved to a top-level
`routing` (issue-142). It is NOT being ignored: `routing.authorizedUsers` decides which
GitHub logins may drive your daemon, and quietly falling back to none is not a decision
this config gets to make for you. Run `/the-loop:upgrade-the-loop` to migrate.

/the-loop:upgrade-the-loop shells out to this command, so an upgrade never hand-edits a config the runtime already knows how to move. Running migrate-config directly does the same job.

An unset version is not refused

The gate refuses exactly two things: a removed key still present, and a config that declares a version older than the current one. A config with no version at all and no removed keys is accepted — there is nothing to move and nothing to lose, and a gate that stops a daemon over a missing bookkeeping key is a gate operators learn to route around.

See also ​

Released under the MIT License.