# yaml-language-server: $schema=https://github.com/victor-software-house/qctl/releases/download/v0.4.2/tasks.schema.json
schema_version: 4
prefix: VER
style:
timezone: "-03:00"
section_order: [queue, horizon, archive]
active: VER-011
queue:
- id: VER-011
title: Finish the Changesets-shaped release loop
scope: verctl
outcome: >-
Fragment in, Version PR, exact-SHA publish, per-package CHANGELOG,
lockfile, already-published, optional natives. No hand-edited
versions.
blocked_by: []
acceptance:
- '`verctl version` writes every manifest, dependent requirement, lockfile, CHANGELOG section, and served file a release changes, removes the consumed fragments, and reports the commands and steps it ran under `after`.'
- 'verctl writes no lockfile: `Cargo.lock`, `bun.lock`, and `mise.lock` change only through cargo, bun, and mise, and verctl only reads them to check agreement.'
- A Version PR is identified by its head repository, its head branch `verctl/release/<base>`, and its base; `check` also requires its diff to equal the version step applied to its base, and no label is read.
- Release state is derived from facts on every run, and a rerun of `verctl release` after a partial failure finishes the release and reports each completed transition as `already`.
- Publish precedes the tag, tags land on the release commit in one atomic push, and a Release with assets stays a draft until every asset is uploaded.
- Cargo and Bun are built-in providers; a package can replace one block with a step, and another stack can declare a whole ecosystem from steps.
- A step is a mise task or an argv that changes only its declared `writes`, and a step that runs after a release declares a `check`.
- 'Bun packages publish through `bun pm pack` and verctl''s own npm registry client, with `auth: oidc` and provenance available, and bun-release is archived.'
- '`.ctl/ver.yaml` declares each concern in one key, refuses unknown keys at every level, and names the replacement of each removed key.'
- Every breaking change shipped in 0.4.0, later work shipped as additive patches, and qctl, forkctl, ctl-core, unixd, and yamled release through 0.4.x.
notes:
- >2-
Original product goal. Do not name the private release-skill in repo docs. Rust and bun are stock examples, not the architecture. Adopt ctl-core fixture trees (CTC-006) instead of more fs::write + indoc copies. 2026-08-17: status/check/prepare are ColorMode tables; publish proves changelog headings + default-branch history (no commit-subject sniff); GitHub notes come from each package CHANGELOG. Hand-edit guard shipped as VER-012 / PR #9. Still open for archive: lockfile-after-bump (VER-013).
- >2-
2026-10-12 (UTC): the operator approved all eight decisions of the OpenSpec change release-state-machine as recommended: (1) derive release state from facts on every run, with no state file or labels; (2) release on push to the release branch, then propose; (3) identify the Version PR by head repository, head branch, and base, and verify its content; (4) one Version PR per base on verctl/release/<base>, with no per-package PRs and no version in the branch name; (5) built-in cargo and bun as providers of building blocks, extensions as steps run through mise tasks or argv, declared ecosystems for other stacks; (6) mise installs, verifies, runs steps, and owns mise.lock, and verctl writes no lockfile; (7) bun-release in Rust as option (b), Bun packs and verctl owns the registry protocol; (8) ship 0.4.0 from an integration branch next, then patches. The held plan on feat/011-refresh-lockfile is replaced by that change and deleted unpushed.
- id: VER-015
title: Asset runners are configuration, not a hardcoded map
scope: verctl
outcome: >-
Each [assets] target names the runner that builds it. The plan
emits what the repo declared, so a public repo stays on free
hosted minutes and a private one can point at its own labels.
blocked_by: []
acceptance:
- A machine is declared once as [runners.NAME] with labels; [ci.NAME] names several, [assets.NAME] exactly one.
- verctl ci and verctl assets print what resolved and the matrix carries it.
- A public repo needs no config to keep macos-latest / ubuntu-latest, and no [ci] to keep one verify job.
- A declared self-hosted or nscloud label set reaches runs-on unchanged, in order.
- An undeclared machine name fails listing the declared ones; a label is never accepted as a name.
- Tests cover default, override, multi-label, fan-out naming, empty list, and a partial undeclared target.
notes:
- >-
2026-08-17: found while adopting the lane in qctl (qctl#3). Two claims first written here were wrong and are corrected: a per-target `runner` override already existed, and a job can be told its runner by an earlier job. The real defects are that `runner` is one string where GitHub takes a label set, that CI has no configuration surface at all, and that an unknown id with a partial record is accepted and reaches `cargo build --target ""`. Shape settled after several wrong turns, recorded so they are not retried: anonymous list rows needing an `id` field (`[[ci.jobs]]`, `runners = [{ labels = [...] }]`) were rejected as ceremony — names are table headers instead; a `runners` list on an asset target was rejected because two machines would overwrite one tarball, which the shape now makes unsayable; and one identical shape per phase was abandoned once it put `os`/`arch` on a job while its labels sat a level below. A name→labels registry is deliberately not the alias table rejected earlier: an alias made an invented word look like a resolved label, whereas a machine name resolves against `[runners]` or fails and never reaches GitHub. M1 (generating ci.yml) and M3 (a repo variable) were considered and rejected for M2, the plan-job bootstrap the publish lane already uses; M3 would move the value out of verctl.toml entirely. Asked why `plan` is not configurable, the inventory settled the law: verctl decides how many jobs there are, the workflow file decides where a fixed job runs. Unary jobs (`plan`, `crate`, `pin`, `prepare`) all have literal machines; only `verify` and `assets`, whose count the repo decides, read config. A static YAML file cannot declare N jobs without knowing N — that fan-out is what config buys, and the machine rides along on it. So `[release]` is dropped, not deferred: publish and pin are inherently unary, nothing decides their count, and a self-hosted-only repo is already served by editing three literals in workflows it owns. `verctl check --ci` was rejected with it — verifying those literals needs a YAML dependency this crate does not have, to check a value the law puts there on purpose.
- id: VER-020
title: check validates served files, and one file has one owner
scope: verctl
outcome: >-
A served file edited by hand, or claimed by both a template and a
pin, fails on the PR that does it instead of at release time.
blocked_by: []
acceptance:
- "`check` runs the same validation prepare does: every pattern still matches at its declared arity."
- "`check` compares every served file to what its template renders at the versions on HEAD."
- A file that a template renders and a [[pins]] entry also names fails before anything is written.
- The failure names the file and which two declarations claim it.
notes:
- >-
2026-08-18: docs/served-files.md §6 describes check as designed, not implemented — only prepare and its dry run validate today, so drift introduced by hand survives until a release. The double-owner gap is real and silent: prepare_report rewrites pins first and renders templates second, so a template would clobber the rewrite without saying so.
- id: VER-021
title: Every config file we own has a JSON Schema, at a URL an IDE can fetch
scope: verctl
outcome: >-
Editing a repo's declarations, tasks.yaml, or a changeset fragment in an
IDE completes the keys and marks a wrong one, in every ctl repo, with
no editor-local setup.
blocked_by: []
acceptance:
- The schemas are generated from the Rust types, so a key that exists in code exists in the schema and no other.
- "Every declaration file a repo writes carries `# yaml-language-server: $schema=<url>`, and so do tasks.yaml and changeset fragments."
- The URL is versioned, and a repo's own schema line is kept current by a [patterns] pin, not by hand.
- "A generated schema that drifts from the types fails a check, the same way a served file does."
- qctl and ctl-core carry the same lines for the same files.
notes:
- >-
2026-08-18, decided with Victor: free and already versioned wins over a prettier host, so this follows the pattern that already exists rather than inventing one. Evidence: qctl ships schema/tasks.schema.json as a release asset, ctl-core's tasks.yaml already points $schema at `releases/download/v0.0.1/tasks.schema.json`, and qctl's own ledger already decided the URL is a pinned tag and never /latest — a schema that changes under an unchanged file is worse than an old one. What is missing is the rest: verctl.toml and changeset fragments have no schema at all, qctl's is hand-written JSON rather than generated from the types, and every pinned URL is bumped by hand. The staleness objection is what VER-018 just answered: a schema URL is a version site like any other, so it becomes a [patterns] entry and prepare keeps it current on the Version PR. mise ships its own schema and we only reference it; template declarations are not a file an editor validates by URL.
- >-
2026-08-18, blocker found by running it: every qctl mutation deletes the schema line it is supposed to protect. `qctl start` on a copy of this file rewrote it through serde_yaml and dropped every comment, line 1 included, so `# yaml-language-server: $schema=…` survives only until the next `q start` or `q archive`. tasks.yaml cannot carry a schema pointer until qctl writes comments back. The same applies to every comment here: none of them survive a verb, which is why an archived blocker is recorded in notes rather than beside the key.
- id: VER-022
title: Move the reusable schema layer into ctl-core
scope: verctl
outcome: >-
Every ctl CLI parses and validates declared input the same way,
from one implementation, instead of three that drift.
blocked_by: []
acceptance:
- "The shared validators (`inside_the_repo`, `one_file_name`, `at_least_one`, `cannot_be_empty`) live in ctl-core and verctl uses them from there."
- "Config loading — read, parse with serde, validate once with garde against the whole document — is one ctl-core entry point."
- "The template renderer's declare-your-own-target mechanism is available to any ctl CLI, not just verctl."
- No verctl-specific vocabulary leaks into ctl-core, and no copy of the layer is left behind here.
notes:
- >-
2026-08-18: src/schema.rs is already shared between verctl's TOML config and its Jinja template declarations, which is the proof it is not verctl-shaped. ctl-core carries the matching row, CTC-008, merged 2026-08-18 as ctl-core#7, so the destination is agreed before anything moves.
- >-
2026-09-28: the validators and config loading moved. ctl-core 0.6.5 ships `validate` and `input`; verctl reads `.ctl/ver.yaml` and fragment frontmatter through `Input`, drops src/schema.rs, gray_matter, and yaml_serde. The template renderer is the part still here.
- id: VER-028
title: Decide who owns a template in the shared .ctl/templates
scope: verctl
outcome: Two ctl CLIs can both render served files from one repo without either rendering the other's templates.
blocked_by: []
acceptance:
- The rule is decided and written where the loader enforces it, not in a PR comment.
- A template belonging to another ctl CLI is either skipped or never in verctl's source tree, and a test proves which.
- docs/served-files.md §1 says the rule, and VER-025's shared-not-namespaced line is either upheld or superseded in writing.
notes:
- >-
2026-08-19, raised on PR #44 and deliberately not acted on there. `templates.source` defaults to `.ctl/templates`, verctl renders every tracked `*.jinja` under it, and `.ctl/` is shared — so once a second ctl CLI serves files, verctl would render its templates too: an unknown name fails the whole Version PR, and one that only reads `versions[…]` gets written to the other tool's target. Nothing collides today, because no other ctl CLI renders anything, which is why this is a row and not a fix. Scoping the directory to `.ctl/templates/ver/` is the obvious answer and is exactly what VER-025 ruled out ("shared rather than namespaced, because a template already declares its own target"), so it is Victor's to reverse, not this row's to assume. The other shape is a template declaring its owning tool, which keeps the directory shared and pays for it with one more export.
- id: VER-029
title: The Version PR exemption must not race its own label
scope: verctl
outcome: The Version PR's own CI passes the hand-edit guard on the first run, without anyone reopening it.
blocked_by: []
acceptance:
- A freshly opened Version PR is exempt on its first CI run, proven on a real release rather than by reading the code.
- The exemption still refuses a hand edit on an ordinary PR, and GITHUB_HEAD_REF is still not an exemption.
- Whatever carries the signal is written down where the guard reads it, so the next reader does not rediscover the race.
notes:
- >-
2026-08-20, hit on the v0.1.1 release and measured, not inferred. The Version PR's own CI failed `check --versions` with "verctl 0.1.0 -> 0.1.1", the exact hand edit the guard exists to catch, on the commit `prepare` had just written. `github::event_labels` reads `pull_request.labels` out of `GITHUB_EVENT_PATH`, and that payload is a snapshot taken when the event fired — but `prepare --pr` opens the PR and then applies `verctl:version` in a second call, so the `opened` payload carries an empty label list. Proof: closing and reopening PR #48 fired a fresh event and the same job printed `exempt · version-pr`, with nothing else changed. The PR carried the label the whole time. Not a new break — v0.1.0's Version PR passed, which is what a race looks like. Do not "fix" it by trusting the branch name: `GITHUB_HEAD_REF` is deliberately not an exemption (VER-012), because a contributor can name a branch anything. Candidates worth weighing rather than one assumed here: have `prepare --pr` create the PR with the label already on it if the API allows, add `labeled` to the workflow's `pull_request` types so the label itself triggers a run, or have the guard ask the API for the PR's current labels instead of reading a stale payload.
- id: VER-030
title: The example catalog teaches one pin, not two
scope: verctl
outcome: A repo copying examples/mise.toml wires the ?ref= tag and nothing else, so no second version site can disagree with the served task.
blocked_by: []
acceptance:
- examples/mise.toml declares no [tools] entry for verctl; the ?ref= include is the whole pin.
- mise install --locked still works for a consumer that copies it, with no lockfile row for a tool it no longer names.
- The template that renders it says why one pin is the rule, so the next reader does not add the second back.
notes:
- >-
2026-08-20, noticed while proving VER-027 at the tag: a clean directory holding only `git show v0.1.1:examples/mise.toml` installs and runs `mise run ver` — but it cannot prove the task's own pin won, because the example's `[tools]` entry names the same version. qctl already settled this as a rule and wrote it into its AGENTS.md: a consumer wires one pin, the `?ref=` tag, because the served task carries its own `#MISE tools` version and wins on PATH, so a `[tools]` entry can only disagree with it — and it needs a lockfile row to survive `mise install --locked`, which is the failure ctl-core#9 hit. qctl queued the same removal for its own example as QCTL-018. This repo's example still teaches two, and it is the example everyone copies, so it teaches the wrong thing twice over. Removing it also makes the pin provable: with no surrounding entry, a consumer that runs the task is running what the task named.
- id: VER-031
title: A pattern that cannot rewrite a whole version refuses it
scope: verctl
outcome: The rule docs/served-files.md states — a prerelease or floating pin fails the release rather than having its numeric head rewritten — is true of the code, not only of the document.
blocked_by: []
acceptance:
- A pattern whose match is immediately followed by more version-ish text fails the release naming the file and the pattern, instead of rewriting the head and leaving the tail.
- A test writes a real prerelease into a pinned file and asserts the release stops and the file is untouched.
- If the engine cannot refuse, docs/served-files.md stops promising it, and says what a pattern author must anchor instead.
notes:
- >-
2026-08-20, found in review of verctl#50 and measured rather than reasoned about. docs/served-files.md §4 says a version is dotted numerics and nothing else, and that "a prerelease or a floating pin is not a version this rewrites: it fails the release rather than rewriting a numeric head and dropping the tail". It does not. With an unanchored `match: "version: {version}"` and a file reading `version: 0.1.1-rc1`, prepare exited 0 and wrote `version: 0.1.2-rc1` — the head moved, the tail stayed, and the release carried on. Kody raised it, our own document contradicted it, and the run settled it: the implementation is the evidence and the document is currently a promise nobody keeps. The condition is exact, and narrower than first written here. A pattern whose `match` ends in literal text already refuses: the test `a_prerelease_pin_fails_instead_of_half_rewriting` uses `"verctl@{version} today"`, and against `verctl@1.0.0-rc.1 today` the digits match but ` today` does not follow, so it fails at arity. Only a match that ends *at* the placeholder has no right-hand boundary, and that is the case that rewrites the head. The author-side answer shipped in #50 as `whole_line: true`, after the first attempt — spelling the closing `---` into the match — was rejected by Victor as a hack: it only worked because `version:` happened to be the last frontmatter key, so adding a key after it would have broken the release. A line-owning pattern supplies the boundary from the line, which is unambiguous. What is left is an inline pattern ending at the placeholder. Do not "fix" it by refusing when the next character continues the version: the discriminator is not local. `download/v{version}` against `download/v1.2.3-linux-x64.tar.gz` has `-` right after the match and is completely correct — the version is 1.2.3 and the rest is the filename. Refusing there would stop real releases, which is why this is a row and not a one-line guard. Either the engine grows a boundary an author can state (as `whole_line` does), or docs/served-files.md stops promising unconditionally and says which shapes are safe.
- id: VER-033
title: Publish follows the dependency graph, not the declaration order
scope: verctl
outcome: A workspace whose packages depend on each other publishes every dependency before its consumers, and says which order it resolved.
blocked_by: []
acceptance:
- Order comes from the manifests verctl already parses, or from a declared list.
- The resolved order is printed, the way ci and assets print what they resolved.
- A cycle fails the run naming the packages in it.
- A single-package repo sees no change.
notes:
- >-
docs/adoption-report.md §2. `publish::plan` iterates `config.packages` in declaration order (src/publish.rs), so a consumer published before its dependency leaves a window where its pinned version does not resolve. Their build tool derives the order from the real dependency graph today, which is what they would be giving up. Their request, verbatim: if it is inferred, print it — "a silent topological sort is a thing nobody debugs until it is wrong".
- id: VER-034
title: Publish declares its own before and after, the way prepare does
scope: verctl
outcome: A repo can gate a publish on its own artifact check and smoke the registry afterwards, without verctl knowing anything about tarballs or registries.
blocked_by: []
acceptance:
- publish.before argv runs after versions are on HEAD and before the first publisher argv; non-zero stops the run.
- publish.after argv runs once, after every package published.
- 'Both receive the resolved package set: name, version, manifest path, publisher.'
- Neither key set is the same run as today.
notes:
- >-
docs/adoption-report.md §3, filed as one row because it is one hook pair. They keep 247 lines they are not asking us to absorb: a packed-artifact gate that proves the tarball rewrote every `workspace:` and `catalog:` dep into a concrete version, and a post-publish smoke that installs the published version from the registry and imports every export. Both are stack-specific and should stay theirs. What they cannot do is wire them, because `Prepare` has `after` (src/config.rs:519) and publish has no equivalent. Closing that asymmetry is the whole ask.
- id: VER-035
title: The commit a release publishes from is proved, and a waiver is said out loud
scope: verctl
outcome: A release refuses a commit whose shape says it is not the one the version tool generated, and an operator who overrides that leaves a line in the log saying so.
blocked_by: []
acceptance:
- The release SHA is an ancestor of the default branch.
- Workflow files at the release SHA do not differ from the default branch.
- The first-parent branch carries exactly one version commit; two always refuse.
- Each check has a named waiver, and a waived run prints which one was waived.
notes:
- >-
docs/adoption-report.md §4. `prove_version_commit` (src/publish.rs:136) is `require_changelog_versions` plus `require_on_default_history` — they keep both and want three more. The workflow-drift one is the non-obvious one and it cost them a release: retrying an old release SHA runs the workflow *at that SHA*, so if permissions changed since, the run can publish to the registry and then fail to tag — the registry is not revertible and the tags are the audit trail. Their waivers are workflow_dispatch booleans and the run prints `waived: …`. Their warning about building it: bind a dispatch input to a step-level `env:` and quote it, never interpolate `${{ inputs.x }}` into a `run:` string — inputs arrive as strings over REST and are not coerced to booleans, in a job holding contents/packages/id-token write.
- id: VER-036
title: A check that cannot run says so instead of passing
scope: verctl
outcome: No verctl guard reports success by having skipped itself.
blocked_by: []
acceptance:
- expand fails on an unresolved {placeholder} instead of dropping the argument.
- prove_default_history prints which condition made it skip.
- One test per site proves the silence is gone.
notes:
- Two sites the consumer report raised separately; they are one law, so they are one row. `publisher::expand` filters out any part containing `{` (src/publisher.rs:151), so a typo'd `{registr}` in a custom argv shortens the command line instead of failing, and the error surfaces downstream wearing someone else's name. `prove_default_history` returns `Ok(())` on four conditions — no repo, no head, unpeelable head, no upstream (src/git.rs:95-104) — each defensible alone, but together the check can pass by not running, in exactly the CI shapes where it matters most. Its doc comment already calls two of them skips; the run does not.
- id: VER-037
title: The README answers the questions a first publish asks
scope: verctl
outcome: A repo publishing through verctl for the first time finds the four answers that currently cost an afternoon each.
blocked_by: []
acceptance:
- What --access does when a registry resolves, and whether a restricted package can be published as public by accident.
- What GITHUB_TOKEN means locally, where a gh session is not it.
- 'That permission_denied: write_package is a package-settings problem, not a token problem.'
- That the tool finalizing a release is the one the workflow pins, not the one at the release commit.
notes:
- >-
docs/adoption-report.md §6, folded into one row because every item is a paragraph the README does not have. The finalizer one is theirs and they call it ugly: their publish job checks out the release commit, so the code that tags and releases *afterwards* also comes from that checkout, and a fix to the tagging logic that landed since is not present on a retry. They restore four files from origin/main between publish and tag to work around it. verctl mostly dodges this by being one pinned binary — but only if the version resolves from the workflow's config rather than the release commit's, which is the sentence to write.
- id: VER-043
title: Move the GitHub Actions into dedicated repositories
scope: verctl
outcome: The version-pr, publish, and asset actions each live in a repository of their own, versioned and released on their own, so a consumer pins an action without pinning the CLI repository.
blocked_by:
- VER-011
acceptance:
- Each action that the release state machine keeps has its own public repository with a root action.yml and a floating major tag.
- This repository no longer holds actions/, and its workflows and every ctl consumer reference the dedicated repositories.
- Each action repository releases through verctl.
notes:
- Follows the VER-011 state machine design, which decides which actions remain and what their inputs are.
- Satisfies the open condition of horizon row VER-014, which needs a first-party Action outside this repository.
- id: VER-044
title: Evaluate a dedicated GitHub App for verctl
scope: verctl
outcome: One decision states whether verctl runs as a dedicated GitHub App, and with which permissions, instead of borrowing a Version PR App token and github.token.
blocked_by: []
acceptance:
- A decision record names the permissions the App would hold, the repositories it would be installed on, and each token it would replace.
- An adopted App has a follow-up row for its rollout; a rejected App has its reason recorded on this row.
- The decision states which release-state transitions need an identity other than github.token, for example opening a Version PR whose events start CI without approval.
notes:
- Today the Version PR workflow mints a token from CHANGESET_VERSION_APP_CLIENT_ID and CHANGESET_VERSION_APP_PRIVATE_KEY, and publish tags and releases with github.token.
- >2-
Related: VER-029, the Version PR label race.
- >2-
2026-10-12 (UTC): the decision also covers whether verctl should verify the ruleset that limits pushes on the proposal branch `verctl/release/<base>` to the release identity, in `check` or a separate command. The release-state-machine design documents that ruleset but does not verify it.
- id: VER-049
title: Deny missing_docs and missing_debug_implementations
scope: src/
outcome: verctl takes the template full lint set; every public item has a doc and a Debug implementation.
blocked_by:
[]
acceptance:
- Cargo.toml drops the local edit that leaves missing_docs and missing_debug_implementations off.
- mise run verify passes with both denied.
- id: VER-050
title: Replace git2 with gix so the openssl ban applies
scope: git2 call sites, Cargo.toml, deny.toml
outcome: verctl builds with no OpenSSL, and deny.toml takes the template openssl and native-tls bans.
blocked_by:
[]
acceptance:
- Cargo.toml has no git2 dependency, and no crate in the graph depends on openssl or native-tls.
- deny.toml drops the local edit, so the template bans apply and mise run verify passes.
notes:
- git2 builds with vendored OpenSSL (features https, vendored-libgit2, vendored-openssl), which needs Perl on every build machine. gix covers clone, fetch, commit, and push over HTTPS with rustls; the shared Rust guidance prefers it where it covers the operation.
- id: VER-051
title: Fill each changelog entry with its pull request and commit, and apply internalAuthors
scope: src/release.rs changelog_sections, src/changelog.rs
outcome: Every changelog entry and Release body links the pull request that added its fragment, and internalAuthors hides the byline it lists.
blocked_by: []
acceptance:
- changelog_sections resolves the commit that added each fragment and the pull request that merged it, and passes both to the template instead of None.
- with_author_filter is applied with the configured internalAuthors, so a listed login renders no byline.
- A test renders an entry with its pull request link and one with an internal author.
notes:
- >2-
release::changelog_sections passes pull_request: None and commit: None, and ChangelogInput::with_author_filter is defined but never called, so internalAuthors is documented and has no effect (main at 9596fb7, verctl 0.3.14). Reported by a consumer adopting verctl that wants a short (#N) per entry and no author credit.
- >2-
Decision (operator, 2026-10-12): render the reference as a short (#N) and credit no maintainer by default; credit outside contributors only. OpenSpec change changelog-references.
- >2-
Follow-up (2026-10-12): the author lookup credited the owner on Version PR #118, so it is removed and no entry carries a byline; VER-054 adds the explicit list on next.
- id: VER-052
title: Group changelog entries under Added, Fixed, and Changed
scope: fragment frontmatter, src/release.rs changelog_sections, the changelog template
outcome: A fragment can name its group, and CHANGELOG.md and the Release body list entries under one heading per group below the version heading.
notes:
- >2-
Options: (a) an optional verctl-only frontmatter key such as kind: added|fixed|changed, which verctl validates and stock Changesets tooling rejects; (b) a summary prefix such as Added: that verctl strips; (c) a fragment file-name convention. Recommendation: (a), since verctl already owns fragment validation. Requested by a consumer adopting verctl.
- >2-
Decision (operator, 2026-10-12): option (a), an optional verctl-only frontmatter key, with a missing key falling to a default group. Starts after VER-051.
blocked_by: [VER-051]
acceptance:
- A fragment may carry an optional verctl-only frontmatter key naming its group; verctl validates it, and a fragment without it falls in a default group.
- CHANGELOG.md and the Release body list one heading per group below the version heading.
- id: VER-054
title: List the maintainers a changelog never credits, in 0.4.0
scope: src/config.rs (Config and its nested structs), src/release.rs origins, src/github.rs, the operator docs; on next
outcome: A repository names its maintainers in changelog.internal_authors, and an entry credits its pull request author only when that author is not listed and not a bot.
blocked_by:
- VER-051
acceptance:
- 'Config and every nested config struct are #[non_exhaustive], so later keys stay additive.'
- .ctl/ver.yaml accepts changelog.internal_authors; an entry carries a byline only when the pull request author is not listed and is not a bot, and a fork check is at most an extra condition.
- Tests cover a listed maintainer on an in-repo branch, the same maintainer from a fork, an outside contributor, and a bot.
- The six ctl repositories list their maintainers and Apps in .ctl/ver.yaml.
notes:
- >2-
Decision (operator, 2026-10-12): an explicit list is the control for crediting no maintainer. author_association cannot decide it: verctl 0.3.15 Version PR #118 credited the owner because the App token read a private member as CONTRIBUTOR. Lands on next because a new Config field is a breaking library change for cargo-semver-checks; main ships no bylines meanwhile.
horizon:
- id: VER-038
title: A tag set is created whole or not at all
scope: verctl
kind: evaluation
outcome: >-
A release that names several tags leaves either all of them or none,
and a rerun from the same commit is a no-op rather than a repair.
open: >-
Whether verctl creates tags git-side instead of letting the Releases API
create them. A first write-up of this row said atomicity is not reachable
through that API because `releases.create` is one call per tag; that
framed the wrong object. Atomicity is wanted over the **tags**, not the
Releases — a Release is derived state that can be recreated from a tag and
a changelog section, a tag is the thing consumers resolve.
Read from a consumer's own implementation rather than their description of
it, the two are separate steps and never interleaved. Tags first:
`git tag -a <tag> <sha>` per released package at an explicit SHA,
annotated rather than lightweight, then a single
`git push --atomic origin refs/tags/a:refs/tags/a …` for the whole set.
Releases after, one per tag that **already exists**, each body sliced from
that package's own changelog between its version heading and the next.
Three rules guard the first step, and they are the row's real content: any
expected tag already at another commit refuses, a *partial* set refuses
rather than being completed, and a full set at the right SHA is skipped so
a rerun is a no-op. Then every tag is re-read with
`git ls-remote --tags origin refs/tags/<t> refs/tags/<t>^{}`, taking the
peeled form first so an annotated tag reports its commit and not the tag
object. A push that exited zero is not accepted as evidence it landed —
the same law as VER-032.
This also settles how VER-032 interacts: once the tag exists before the
Release is created, `target_commitish` is documented as unused, so the
git-side path makes that field moot rather than something to set. Taking
this row is therefore an alternative to VER-032's first acceptance line,
not an addition to it.
2026-08-20, four findings from reading our own code before costing this,
because most of the expected objections are not real:
(1) Nothing chose the Releases API. There is no decision anywhere; verctl
needs a Release for notes and asset upload, `releases.create(tag)` makes
the ref as a side effect, and no tag step was ever written. The tag is a
byproduct, which is exactly why nothing checks where it lands.
(2) verctl already pushes with git, every release.
`git::commit_branch_and_push` (src/git.rs:376) writes the Version PR
commit and pushes it through git2. This is not a new capability.
(3) No grant changes. That push authenticates with
`Cred::userpass_plaintext("x-access-token", token)` against an anonymous
HTTPS remote verctl builds itself, so it needs neither `actions/checkout`
persist-credentials nor `http.extraheader`, and `contents: write` already
covers both creating a Release and pushing a ref. Same token, same scope,
a path already exercised.
(4) **libgit2 cannot do `--atomic`, and this is the real decision.**
`git_push_options` carries pb_parallelism, callbacks, proxy_opts,
follow_redirects, custom_headers and remote_push_options — no atomic
flag, and the string does not occur in libgit2-sys at all
(git2 0.21.0). GitHub does advertise it — read live from
`/info/refs?service=git-receive-pack`: `report-status report-status-v2
delete-refs side-band-64k ofs-delta atomic object-format=sha1 quiet
push-options`. So the capability is there to use and only the client
cannot send it.
2026-08-20, the option space, because two of these were nearly taken on a
wrong premise. Shelling out to a `git` binary and accepting a
non-atomic multi-refspec push are both ruled out by the operator.
Gitoxide's high-level push does not exist and is **not planned** —
GitoxideLabs/gitoxide#306 was closed `not_planned` on 2026-07-22 with
`ReceivePack` for V1 and V2 never ticked, and #2434 (signed pushes) the
same day. REST `POST /git/refs` and GraphQL `createRef` are one ref per
call with no transaction, and libgit2's `git_transaction` is local refs
only. Supporting only single-tag templates would abandon the case VER-039
exists for. That leaves two.
**Fork libgit2.** Six edits mirroring how `push-options` was added:
`GIT_CAP_ATOMIC` in smart.h, a fifteenth bit in `transport_smart_caps`
(fourteen today), a bool beside `report_status` in push.h, two blocks in
`gen_pktline` at lines 811 and 840, caps parsing, and a git2-rs
`PushOptions::atomic`. Cheap to write. Expensive to own: verctl is
published on crates.io, which forbids git dependencies, and `[patch]` is
honoured only in a top-level workspace, so `cargo install verctl` would
need republished `vsh-libgit2-sys` and `vsh-git2` — whole libgit2 plus
vendored PCRE2 — rebased on every upstream release. There is no smaller
surface: git2's features are link and vendoring switches, and
libgit2-sys/build.rs compiles the entire C tree regardless.
**Assemble gitoxide's lower crates.** What #306 abandoned is high-level
push — negotiation, delta generation, ref orchestration. The transport is
published and maintained: `gix-transport` 0.57.1 already has
`Service::ReceivePack => "git-receive-pack"`, a blocking HTTP
smart-protocol client with curl and reqwest backends, `request.rs`,
side-band reading in `bufread_ext.rs`, and `gix-credentials`, all
feature-gated. With `gix-packetline` for framing, verctl writes the
command list and the capability word and nothing else. Our case is
degenerate — tags at a known SHA whose commit is already on the remote —
so the abandoned half barely applies. Cost: both libgit2 and gitoxide in
the tree, and gitoxide bumps semver often across ~40 crates.
**Upstreaming is not a plan, measured rather than assumed.** The exact
precedent is adding a push capability to libgit2, done once and attempted
twice: #6439 opened 2022-12-01 and merged 2024-02-07, **433 days**; #6281
opened 2022-04-20 and closed **unmerged** 2024-03-09, dead after 689 days.
Two other capability PRs took 279 and 234 days. libgit2's 3.1-day median
merge is maintainer housekeeping; 84 of its 106 open PRs are over a year
old. Compare gitoxide: 171 PRs merged in 90 days, 0.5-day median, 15 open,
**none older than a year**. So a fork would be a fork indefinitely, and
"upstream first, fall back to forking" is a year-long lottery.
Two smaller things to settle with it. An annotated tag needs a tagger, and
`signature()` (src/git.rs:472) resolves one from repo config, then
GIT_AUTHOR_NAME/GITHUB_ACTOR, then `verctl` — on a runner that inherits
whatever git config is there, so it wants pinning rather than inheriting.
And the failure window moves: today one call leaves tag-and-Release or
neither, git-side leaves a tag with no Release, which is the better half
to be left holding because a Release is recreatable and a tag is not.
Whatever is chosen must be declared and defaulted rather than wired in,
and tested at the same depth as VER-039 asks: the three guard rules each
proven to refuse, the re-read proven to catch a tag that resolves
elsewhere, and a retry over a half-created set proven to complete it.
The other half is the Release body: per-package Releases each want only
their own package's changelog section, which `release::notes_for` already
extracts and currently concatenates. Cheap once VER-039 exists, so it
waits on that rather than on this.
- id: VER-019
title: Triage the review backlog from the closed PRs
scope: verctl
kind: evaluation
outcome: >-
Every review finding on already-merged code is either a named
defect with a fix, or written off with the reason.
open: >-
2026-08-18: nine findings were raised on PRs #4, #6, #9, #12, #13,
#14 and never triaged, because those PRs were closed with the
comments unread. Listed so they stop being invisible; none is
confirmed except where noted. Security-shaped first.
(1) #12: CARGO_REGISTRY_TOKEN is exported into the same step that
compiles this repo's source, so a build script sees the crates.io
token. Same trust boundary as publishing at all, but publishing
from the released binary instead of a fresh build would remove it —
see VER-016, which wants that step off the compiler anyway.
(2) #12: `${{ matrix.id }}` and `${{ needs.crate.outputs.tag }}`
are interpolated straight into `run:` bodies. The values come from
verctl.toml on the default branch, not from a fork, so this needs
write access to exploit; passing them through `env:` costs nothing.
(3) #9: the version guard runs on a shallow checkout in some lanes,
where `check --versions` cannot reach the merge base.
(4) #6: `consume_fragments` runs only in the `--pr` branch, so a
local `prepare` leaves the fragments it already applied.
(5) #6: publish forces `--access public` for bun packages.
(6) #6: no npm auth is set up for the bun publisher.
(7) #6: `tag_for` falls back to the first package's version, which
is wrong for a repo whose packages version independently.
(8) #4: HEAD is not restored after the Version PR branch write, and
`add_all(["*"])` stages more than the plan named.
(9) #14: `recurse_untracked_dirs(true)` walks into large ignored
trees. Retired 2026-08-18: #13's invalid `runner` context in a
workflow-level `env` is gone — no workflow or example uses the
`runner` context, and actionlint 1.7.12 passes every file.
- id: VER-016
title: The plan job should not install a compiler to read the declarations
scope: verctl
kind: evaluation
outcome: >-
The ci.yml bootstrap costs what reading .ctl/ver.yaml costs, and
verify stops waiting on a toolchain it does not share.
open: >-
Measured on run 32078158505: plan took 33s, of which 21s was mise
plus rust-cache, and verify did not start until 37s in. It needs
verctl and .ctl/ver.yaml, not cargo. The tarball is the fix and it
is gated, not undecided: `git show v0.0.2:src/ci.rs` does not
resolve, so the published binary has no `ci` verb and
MISE_ENV=release cannot plan until a release carries VER-015.
Flip the plan job then and delete its toolchain steps. Rejected
meanwhile: a stable-rust plan job, which trades a second
toolchain and a cold cache for ~13s; and one rust-cache key
across plan and verify, since rust-cache saves only when the key
is absent, so plan's dev-binary target dir would land first and
leave verify a thinner cache than its own. Efficiency only — the
runner contract itself is settled (VER-015).
- id: VER-013
title: Stock lockfile refresh after cargo bumps
scope: verctl
kind: evaluation
outcome: >-
After a cargo version write, Cargo.lock is regenerated so
publish --locked does not fail.
open: >-
Decide whether this is default after for the cargo driver or a
[prepare].after the repo opts into. 2026-08-18: qctl 0.0.2 failed
on exactly this and is evidence for the default. The cargo driver
already names `cargo generate-lockfile`, but `apply_plan` returns
it as a follow-up that only prints in the `next` column, so every
cargo consumer has to restate it as `[prepare].after` plus a
`Cargo.lock` stage glob — and a repo that does not know to is one
Version PR away from a dirty-tree `cargo package --locked`.
Running the driver's own after is the fix; the opt-in escape is
then a `[prepare]` that overrides it. The command itself is settled
and no longer part of the question: measured on a scratch copy with
the manifest bumped to 0.0.9, `cargo generate-lockfile` printed
`Locking 138 packages` and `Adding serde_yml v0.0.12` for a 3-line
diff, while `cargo update --workspace` printed `Locking 1 package`
for a 1-line diff. The driver, this repo, qctl, and ctl-core all
name the narrow one now.
- id: VER-014
title: First-party composite Action packages
scope: verctl
kind: evaluation
outcome: >-
action.yml packages get floating major tags without npm.
open: Need a first-party Action that is not this repo's own actions/.
- id: VER-010
title: Stock JS publish for npm, pnpm, and yarn
scope: verctl
kind: evaluation
outcome: >-
package.json still publishes through one table of argv. Bun stays
the default. npm/pnpm/yarn are extra rows, not new verbs or
Action commands.
open: >-
Need a consumer that is not Bun. Infer from packageManager /
lockfile the same way detect.rs already does for install. Action
stays `verctl publish`.
- id: VER-008
title: Generic regex version writer
scope: verctl
kind: evaluation
outcome: Packages that are not Cargo or package.json can still bump via a declared pattern.
open: Need a real third manifest (not a hypothetical) before adding a regex writer.
- id: VER-006
title: Conventional Commits synthesize the same fragments
scope: verctl
kind: deferred
outcome: An opt-in mode writes .changeset/*.md from commit types.
open: Default stays off. Fragments remain the human input.
- id: VER-007
title: OIDC publish backends for cargo and bun
scope: verctl
kind: deferred
outcome: Public npm uses trusted publishing, not NPM_TOKEN.
open: bun publish --tolerate-republish already ships. OIDC is the remaining auth path.
- id: VER-023
title: Is this how a modern Rust CLI is built?
scope: verctl
kind: evaluation
outcome: >-
The crate layout, error handling, test structure, and fixture
approach are what current Rust practice recommends — or we know
exactly where we differ and why we keep it.
open: >-
2026-08-18, raised by Victor: nothing here is unmanageable, but the
pattern was never checked against the field. Questions worth
researching rather than guessing: flat `src/*.rs` modules versus
grouped ones as the crate keeps growing; anyhow everywhere versus a
typed error at the library boundary with anyhow only in main; unit
tests in-file versus `tests/` (both are used here, with no stated
rule); hand-written table tests versus a fixture or snapshot crate;
and how much of the answer belongs in ctl-core so all three CLIs
share it (VER-022). Deliverable is a short written standard in
ctl-core plus a list of what to change here, not a rewrite.
- id: VER-045
title: Serve the shared Rust baseline through verctl
scope: verctl
outcome: 'The shared Rust baseline that ctl-core renders reaches each Rust repository through verctl''s Served files block: `verctl render` writes it and `verctl check` reports its drift.'
kind: deferred
open: 'Paused by the operator: rendering files outside versioning is outside verctl''s scope unless a more general shared mechanism exists. Waits on the ctl-core side''s discovery of such a mechanism (a templating tool, a mise plugin, or a crates monorepo).'
notes:
- >2-
2026-10-12 (UTC): the operator paused this row. verctl does not render the Rust baseline files for now, because writing templates outside versioning is outside verctl's scope unless a more general shared mechanism exists. The ctl-core side is writing a discovery document first, and the standard Rust mise tasks ship separately as a mise remote task include from ctl-core. ctl-core's `baseline` feature stays in 0.6.12 meanwhile. Do not start this row until that discovery names a mechanism and the operator resumes it.
- >2-
2026-10-12 (UTC): the operator decided the shared Rust baseline joins verctl's Served files block. There is no `ver baseline` subcommand: `verctl render` writes the baseline files and `verctl check` reports their drift, in the same report and JSON as other served files, with one non-zero exit and one line per drifted file. ctl-core's `baseline` feature returns plain `ctl_core::served::Served` values plus marked-block entries, read from `.ctl/rust.yaml`, whose schema ctl-core owns. A marked block in `Cargo.toml` holds only `[lints.*]` or `[workspace.lints.*]` tables, never the keys the version step writes. When the baseline changes the rust entries in a mise config, the lock for that environment is regenerated whole with `mise lock --platform …`, never edited as text; the ctl-core side reports that a per-tool `mise lock rust` left a stale entry when the entry changed shape, not yet reproduced here. Default timing is a patch after 0.4.0, since it adds a source to an existing block.
- >2-
2026-10-12 (UTC): agreed with the ctl-core side: verctl owns the served `.github/workflows/ci.yml`, and the baseline renders no workflow. The Rust steps it carries are those of ctl-core ci.yml at 02941ff (https://github.com/victor-software-house/ctl-core/blob/02941ff7bd0d1d7adf9fb07db59599ecf3da66f0/.github/workflows/ci.yml): env `MISE_ENV: dev,ci` and `CARGO_TERM_COLOR: always`; checkout with `persist-credentials: false`; `jdx/mise-action` at the pinned mise with `cache: true`, before `jdx/mr-boxington-action`, because the mbx cache key hashes the rustc on PATH; then `mise run verify` and `mise run deny:advisories`; and, for a published library only, `mise run semver` and `mise x -- cargo package --locked --all-features`. The repository kind and published flag come from `.ctl/rust.yaml`.
- >2-
2026-10-12 (UTC): ctl-core 0.6.12 shipped the `baseline` feature (crates.io, rust-version 1.99.0; https://github.com/victor-software-house/ctl-core/releases/tag/v0.6.12). API at fd6c61f (https://github.com/victor-software-house/ctl-core/blob/fd6c61f5b1967888090052b996c22370325b0b31/src/baseline.rs): `baseline::load(root)` reads `.ctl/rust.yaml`; `baseline::render(root, &config)` returns `Rendered { served, owned }`, where `served` holds whole files (rust-toolchain.toml, rustfmt.toml, clippy.toml, deny.toml, lefthook.yml, and Cargo.toml and mise.dev.toml with only their marked block replaced) for `render` and `check` to treat like any other served file, and `owned` lists files the repository keeps, each with a reason, reported as owned rather than drift; `BaselineError` names the file, such as a missing marker. `Config` is serializable, so the served ci.yml template can read `published` and `layout`. Adopting it requires rust-version 1.99.0, `.ctl/rust.yaml`, and the two marker pairs; ctl-core is the worked example (`.ctl/rust.yaml` and `tests/baseline.rs` at fd6c61f).
- id: VER-053
title: Read and write a version in YAML through yamled
scope: src/driver.rs Format, src/config.rs
outcome: A package can declare format yaml with keys, and verctl edits that one value without rewriting the rest of the file.
kind: deferred
open: 'No second consumer needs it: the first one keeps its version in YAML and uses a command driver.'
notes:
- yamled already makes format-preserving YAML edits; verctl adds no other YAML parser. Drivers parse only toml and json today.
archive:
- id: VER-048
title: Take the shared Rust setup from the Copier template
scope: .copier-answers.yml at templates v0.0.5, the files the template renders, mise.toml, mise-tasks/, the workflows, and every mise lock
outcome: verctl renders its repository setup from the shared template, builds on the pinned stable 1.99.0, runs the shared task catalog, and arms its git hooks on mise install.
completed: 2026-10-12T07:54:24
evidence:
- 'Shipped in #112. verctl renders its setup from the template at v0.0.5, builds on stable 1.99.0, runs the ctl-core catalog, and arms its hooks on mise install; CI passed Plan, verify, and Template, and 0.3.14 published from Version PR #113, which merged itself.'
disposition: completed
- id: VER-047
title: Merge the Version PR automatically once required checks pass
scope: actions
outcome: A repository can opt in so its Version PR merges itself when the default branch's required checks pass, with no manual merge per release.
notes:
- >2-
2026-10-12 (UTC): 13 Version PRs were merged by hand across the six ctl repositories in the seven days before, each with one commit and green checks. All six allow auto-merge. None had a required status check on main, and the forkctl and unixd Version PRs ran no CI because they opened with the default token.
completed: 2026-10-12T06:04:02
evidence:
- 'Shipped in #110 (verctl 0.3.13). Version PR #111 had auto-merge enabled by the action and merged itself after Plan and verify passed, and publish released 0.3.13. Rulesets require CI on main in the six repos, and qctl #98, forkctl #91, ctl-core #106, unixd #28, and yamled #25 set auto-merge.'
disposition: completed
- id: VER-046
title: Declare the lock platforms so mise install leaves the locks unchanged
scope: mise
outcome: A `mise install` on a supported machine changes no tracked file, so hooks and CI stop rewriting mise.dev.lock.
notes:
- >2-
2026-10-12 (UTC): on mise 2026.10.7 a fresh `mise install` on linux-x64 added cargo-deny 0.20.2 entries for linux-arm64, linux-arm64-musl, linux-x64-musl, macos-x64, and windows-x64 to `mise.dev.lock`, and every local commit hook rewrites the same file. Cause: `mise.dev.lock` records committed for all seven platforms but cargo-deny for only linux-x64 and macos-arm64, and with `lockfile_platforms` unset mise keeps every platform already recorded in the lockfile, so automatic locking fills cargo-deny up to the seven (https://github.com/jdx/mise/blob/4599c53b4286ff101de876f8122feec0797b48b2/settings.toml, the 2026.10.7 docs). Declare the setting, then regenerate each lock whole with `mise lock --platform …` so every tool records the same platforms.
- >2-
2026-10-12 (UTC): entry order in a mise lock depends on the mise version: with the same two rust entries in config, mise 2026.10.3 writes the nightly entry first and 2026.10.7 the stable entry first, with identical content (run locally on both official binaries). `min_version` sets only a floor, so it cannot hold every machine to one version. Decision: verctl compares a lockfile's parsed entries, never its bytes (release-state-machine design, section 4), and byte-identical locks are expected only from the mise version CI pins.
completed: 2026-10-12T04:50:29
evidence:
- 'https://github.com/victor-software-house/verctl/pull/109: mise.toml declares lockfile_platforms linux-x64 and macos-arm64, and the three locks were regenerated with mise lock on mise 2026.10.3; a fresh linux-x64 mise install left git status clean.'
disposition: completed
- id: VER-042
title: A new package releases 0.0.1 first
scope: verctl
outcome: prepare and check refuse a minor or major bump on a package at 0.0.0, including its pre-releases and builds, unless the package opts in, so a first release is 0.0.1.
completed: 2026-10-01T12:40:05
evidence:
- 'prepare and check refuse a minor or major fragment on a package at 0.0.0; first_minor opens at 0.1.0 (PR #101).'
disposition: completed
- id: VER-041
title: Prove the label trigger on a real Version PR, then archive adopt-ctl-core-0-6
scope: ci
outcome: The next Version PR passes CI without a manual reopen, and the finished OpenSpec change moves to the archive.
links:
- https://github.com/victor-software-house/verctl/pull/80
completed: 2026-09-23T23:00:19
evidence:
- https://github.com/victor-software-house/verctl/actions/runs/35944317578
- https://github.com/victor-software-house/verctl/pull/82
disposition: completed
- id: VER-040
title: Adopt the ctl-core 0.6 presentation defaults
scope: presentation
outcome: 'verctl output uses the shared default look: borderless records, the id role for package names, pretty JSON, and an 80-column fallback in piped captures.'
links:
- https://github.com/victor-software-house/ctl-core/tree/main/openspec/changes/choose-visual-identity
completed: 2026-09-23T19:25:18
evidence:
- https://github.com/victor-software-house/verctl/pull/77
disposition: completed
- id: VER-032
title: A release tags the commit it published
scope: verctl
outcome: The tag a release creates resolves to the commit that was built and published, not to whatever the default branch points at when the API call runs.
notes:
- >-
2026-08-20: found while checking the consumer report in docs/adoption-report.md, which asked only that a pushed tag be verified after the fact. It is worse than that. `github::ensure_release` calls `releases.create(tag)` and never sets `target_commitish`, so GitHub tags the default branch's HEAD at the moment of the call — not the commit that was built and published. v0.1.3 is right only because main did not move: `git rev-parse v0.1.3^{}` and `git rev-parse origin/main` are both 2fd33a6c5713. Anything merging to main during the publish job puts the tag on a tree that was never built, which is the served-files contract's one premise (docs/served-files.md:3 — "its version must be correct in the tree the tag names"). Nothing else creates the tag: no `git tag` or `refs/tags` anywhere in src/, no tag step in .github/workflows/publish.yml, and `git cat-file -t v0.1.3` says `commit` — a lightweight tag, which is what the Releases API makes. The retry path needs the same treatment for a different reason: `ensure_release` returns early when `get_by_tag` succeeds (src/github.rs:249-250) without looking at where that tag points, and `target_commitish` cannot correct a tag that already exists. If VER-038 is taken, the first acceptance line here is superseded rather than also done: a tag created git-side already exists by the time a Release is made, and the API documents `target_commitish` as unused in that case. The re-read lines stand either way.
completed: 2026-08-22T13:59:28
evidence:
- ensure_release sets target_commitish to HEAD SHA.
- The tag is re-read after create and on retry; a mismatch fails.
- 'wiremock: create_tags_the_published_commit_not_the_default_branch'
disposition: completed
- id: VER-039
title: A release declares what its tags are named
scope: verctl
outcome: A repo says the shape of its release tags in one line with a sane default, and a release that cannot name what it shipped refuses instead of guessing.
notes:
- >-
The shape, so the live defect is not waiting on discovery. The consumer's strawman is a `tags.template` plus a `releases: per-package | aggregate` enum; the enum is redundant, because **the arity falls out of the template**. A template with no `{name}` can only name one tag; one with `{name}` can only mean one per package. Two fields that cannot disagree should be one field, and the one that survives is the one a reader can look at and know what they get. So: one optional line, defaulting to `v{version}`, in the placeholder vocabulary `patterns.match` already uses — a single-package repo writes nothing and a monorepo writes `template: "{name}@{version}"`.
- >-
What this kills: `tag_for` (src/publish.rs:158) dedups the versions and, when more than one survives, tags with the *first declared* package's version — a value with no relationship to the release, chosen silently. With a template there is always an answer: either the shape names every package, or it cannot name this release and says so.
- >-
Deliberately not here, because it is where the design risk actually is: splitting the changelog so each Release carries only its own package's section, and creating a tag set all-or-nothing. Atomicity is not reachable through the Releases API — they get it from `git push --atomic` on locally created tags, which is a different tagging path, so it stays with VER-038. Idempotent retry is the weaker property that is reachable now and is what the acceptance asks for. VER-032 is orthogonal: it fixes which commit a tag names, whatever the tag is called.
completed: 2026-08-22T13:04:12
evidence:
- 'feat/039-declare-the-tag-shape: tags.template + per-package Release bodies'
disposition: completed
- id: VER-027
title: The served task runs the verctl its own pin names
scope: verctl
outcome: >-
`mise run ver` runs the version `tasks/ver/ver` pins, not whichever
verctl the machine happens to hold.
notes:
- >-
2026-08-19, measured in qctl: `mise where <tool>` resolves a version from the surrounding config, so a task-scoped `#MISE tools` pin guarantees the install and never the selection. With 0.0.1, 0.1.0 and 0.1.1 installed and one task pinned to 0.0.1, `exec qctl "$@"` ran 0.0.1 and `exec "$(mise where …)/qctl" "$@"` ran 0.1.1 — mise already puts the task's pinned tool first on PATH. Found by running `mise run q -- status` in this repo, whose include is pinned to qctl v0.0.1 and which still got the schema-3 complaint that only 0.1.1 can produce. `tasks/fork/fork` in forkctl carries the same line and the same bug. That repo has no ledger, so this row is where it is written down until it does.
completed: 2026-08-20T10:38:13
evidence:
- verctl#47 merged 8389b46, released v0.1.1. tasks/ver/ver is served from .ctl/templates/ver.jinja and execs verctl from PATH; git show v0.1.1:tasks/ver/ver pins 0.1.1, mode 100755.
- 'Measured on this machine the way qctl measured it, with 0.0.1 through 0.1.0 installed and a task pinned to 0.0.4 while the config said 0.1.0: exec verctl ran 0.0.4 and exec "$(mise where …)/verctl" ran 0.1.0. The pin is honoured only by the plain exec.'
- 'No release shipped one half. Before merging, the hand-written file at 0.1.0 was proven equal to what the template renders, in a scratch clone with the template tracked: the entire diff was the one version line. The Version PR then rewrote exactly that line to 0.1.1.'
- 'The tag installs itself, proven on a clean directory holding only git show v0.1.1:examples/mise.toml: mise install then mise run ver -- --version printed verctl 0.1.1. Caveat kept honestly: the example still carries a [tools] entry naming the same version, so that run cannot prove which pin won — queued as VER-030.'
- forkctl left alone deliberately. tasks/fork/fork carries the same line and its pin reads 0.0.22 while the latest release is v0.0.22, so it is currently correct — but that repo has no verctl lane and no template, so nothing keeps it correct through the next release. Changing the exec there without a generator would create exactly the freeze this row warns about.
disposition: completed
- id: VER-026
title: An untracked template in the source tree stops the release
scope: verctl
outcome: >-
A template a repo committed to nothing renders nothing and says so.
Adding a served file and getting silence is not a state a release
passes through.
notes:
- >-
2026-08-19, found in qctl while adopting served files: `prepare --dry-run` listed the one `[[pins]]` file and neither of the two templates, with no diagnostic, because they were written but not yet staged. Diagnosed by reading `templates::sources`, which filters `git::tracked` — and the current test pins that silence as intended, asserting an empty plan and an unchanged served file. Refusing to render is right: `prepare` auto-stages rendered outputs, so a template git does not carry would put a served file on the tag whose source is not in the tree, and `check` would have nothing to compare on a fresh clone. What is wrong is what happens next — the release continues and serves the stale hand-authored file, which is the exact failure served files exist to end. It fails open, once per repo, at the moment somebody is learning the feature.
completed: 2026-08-20T10:38:13
evidence:
- 'verctl#47 merged 8389b46, released v0.1.1. A template in the source tree the repo neither tracks nor ignores fails the run naming the file; proven live on this repo with a fragment present: exit 1, and the target file was not created.'
- 'Both halves mutation-proved rather than assumed green: dropping the refusal fails an_untracked_template_stops_the_run_and_one_outside_is_ignored, and checking unconditionally fails a_tree_with_no_repository_has_no_templates. That second test previously planted no template on disk and so could not have caught it.'
- Review widened the rule twice, both correct. An ignored template is disowned rather than forgotten, because ignoring a file is the same statement about publishing that the index makes — git::tracked's own doc already said so, so refusing one contradicted the module. And symlinks are no longer followed, so a directory outside the repository cannot have its templates counted as ours.
- The acceptance line said 'git does not track'; what shipped is 'neither tracks nor ignores', which is the correction above and not a narrowing.
- 'Kody''s stated mechanism for the symlink case was wrong and is recorded as such: there is no infinite recursion, because present returns empty when read_dir fails, so an ancestor loop terminates past PATH_MAX — measured at 0.03s with the following form restored. The real defect was the foreign file, and the test asserts that; reverting makes it fail naming .ctl/templates/linked/foreign.jinja.'
- docs/served-files.md §1 no longer claims an untracked template renders nowhere, §5's failure list carries the case so its 'exactly one thing is skipped silently' reads true again, and verctl instructions, AGENTS.md and skills/verctl/SKILL.md carry the whole rule.
disposition: completed
- id: VER-024
title: Delete the retired [assets].targets shim
scope: verctl
outcome: >-
A config key that no longer exists is not still half-parsed. The
migration message has done its job and goes with it.
notes:
- >-
2026-08-18: written down because it was not. The shim exists only so a repo still on `targets = [...]` reads the migration instead of a serde type error, and its own doc comment says "remove once qctl and ctl-core have bumped their verctl pin" — a removal condition with no row to enforce it, which is how compatibility code becomes permanent. Verify the condition rather than assume it: grep both consumers for the key and read their pins.
completed: 2026-08-19T23:57:38
evidence:
- 'Removed in verctl#44 with the migration that was its stated removal condition: Assets::retired_targets, the bail in assets.rs, and the test asserting the message are all gone.'
- 'The condition was checked, not assumed: qctl declares assets.darwin-arm64 / assets.linux-x64 and ctl-core declares no assets, and both moved their verctl pin to 0.1.0 in the same slice (qctl#32, ctl-core#10).'
- A repo still writing the old key now gets serde's unknown-field error, which Config's new deny_unknown_fields makes loud rather than silent.
disposition: completed
- id: VER-025
title: Declarations live in .ctl/ver.yaml
scope: verctl
outcome: >-
A repo has one `.ctl` directory that every ctl CLI shares, and verctl's
declarations are one YAML file inside it. `verctl.toml` is gone, not
tolerated.
notes:
- >-
2026-08-18, decided by Victor and recorded late — this row exists because the decision lived in a conversation instead of the repo, which is the failure the cold-start rule is about. His words, twice: first "keep the damn config in there, .verctl/config.toml etc etc … anything more than that, then we should use a damn directory already", then, on seeing this written up as an open question, "we should just fucking drop fucking verctl.toml and keep shit under .verctl/{config, etc etc}.yaml". The second supersedes the first on two points: the format is YAML, and `verctl.toml` is dropped rather than still recognized. A per-concern split, YAML only, with both consumers migrated, is what was agreed at the time. This is a break to a contract we own, so the rule applies: inventory every producer and consumer, migrate them in the same slice, remove the old path. VER-021 declares this its blocker rather than saying so in prose, because a schema written against verctl.toml is written twice. VER-024 is the smaller version of the same problem and can ride along.
- >-
2026-08-19, from qctl QCTL-019: this slice picks a name two tools have to live with, so pick it on purpose. qctl will need project config of its own — a registry naming the ledgers a repo reads — and if this lands `.verctl/` while that invents `.qctl/`, two tools that ship together have two conventions and nothing written down about why. Decided by Victor the same day, and it supersedes this row's original shape: one `.ctl/` directory, one file per tool, and the file name drops the ctl — `.ctl/ver.yaml`, `.ctl/q.yaml`, `.ctl/fork.yaml`. The names match the tasks a consumer already runs (`mise run ver`, `mise run q`, `mise run fork`), so a file is named after the verb the operator types rather than after the crate that reads it, and ctl-core has one place to look when it becomes the shared layer. `.verctl/` is not the target. What is superseded, explicitly: one YAML file per concern. The sections are already named inside the document, so a directory of ten files split what a reader opens without splitting what they have to understand. If it ever does grow, the loader can accept a directory beside the file without a second migration — that is a follow-up, not this slice. Templates keep a directory of their own at `.ctl/templates/`, shared rather than namespaced, because a template already declares its own target and nothing in one is verctl's except who renders it. One constraint from that side, so the shape is not chosen blind: a committed file can only carry what is true on every machine. qctl's registry splits accordingly — prefixes in the repo, paths on the machine — and any `.ctl/` convention should expect the same split rather than assume everything a tool declares is committable.
completed: 2026-08-19T23:57:38
evidence:
- 'verctl#44 merged f3553c0: .ctl/ver.yaml holds every section verctl.toml held, templates moved to .ctl/templates/, verctl.toml is not read at all.'
- 'Cross-section rules still fire on one merged document: every_pattern_is_listed, an undeclared runner on a job or asset target, a pin outside the repository. Loading is still one boundary, now Config::parse.'
- 'v0.1.0 released 2026-08-19 (tag commit 52a7d374), both tarballs attached; git show v0.1.0:verctl.toml fails with "path does not exist", and .ctl/ver.yaml is there.'
- 'Every known consumer moved with its verctl pin in the same commit: ctl-core#10 -> 28920b8, qctl#32 -> 5b86881. Both bump mise.release.toml to 0.1.0 and every action ref to 52a7d374, and both were exercised with the released binary, not a checkout.'
- 'The consumer list is two, and forkctl is the third repo in the family that is not on it: origin/main carries no verctl.toml, no mise.release.toml, and no verctl action — it releases through mise-tasks/release.sh. Checked rather than assumed, because its served tasks/fork/fork looks like a verctl lane and is not one.'
- "This repo's own mise.release.toml moved to 0.1.0 too. Its lanes run MISE_ENV=dev, so CI never touched it, but a local MISE_ENV=release would have installed a 0.0.4 that cannot read the file this repo now writes."
- 'qctl''s two templates moved with git mv and still render: a dry run there rewrites README.md, examples/mise.toml, and tasks/q/q.'
- 'Two shapes were decided under this row rather than assumed: paths stay relative to the directory holding .ctl/ (config::root_of, table test), and an arity with a bound is {exactly: N} / {at_least: N} because a newtype variant would want a YAML tag. VER-028 records the shared-templates question review raised.'
disposition: completed
- id: VER-018
title: A repo declares the files it serves instead of scripting them
scope: verctl
completed: "2026-08-18T19:50:03"
disposition: completed
outcome: >-
Anything a release leaves stale is rewritten by declaration,
whatever file it lives in, without verctl learning that file's
format.
evidence:
- "github.com/victor-software-house/verctl/pull/31"
- "37a59ed on main"
- "docs/served-files.md is the contract; AGENTS.md carries the schema standard"
- "templates declare path / name / executable as top-level Jinja exports, discovered through the git index"
- "patterns are named once as [patterns.<id>] and listed by every file that carries one, with a declared arity"
- "examples/verctl.toml exercises both mechanisms and is parsed by tests/examples.rs"
- "mutation runs: 8 of 10 pin, 8 of 9 config-schema, 2 of 2 git — the uncaught ones do not compile"
- "left to VER-020: check does not run the same validation, and no rule refuses a file two declarations both own"
- id: VER-017
title: Pins ride the Version PR, and publish pushes only the tag
scope: verctl
completed: "2026-08-18T19:50:03"
disposition: completed
outcome: >-
The released tree carries its own pins, so a consumer that fetches
by tag or ?ref= gets the version it asked for.
evidence:
- "github.com/victor-software-house/verctl/pull/28"
- "git show v0.0.4:examples/mise.toml names 0.0.4 in both the [tools] pin and the ?ref="
- "the Version PR lane runs MISE_ENV=dev, so 0.0.4 was prepared by the build that ships the mechanism"
- "publish pushed only the tag; v0.0.4 carries the two native tarballs"
- "mise.release.toml stays hand-bumped, as the bootstrap exception requires"
- id: VER-012
title: Block hand-edited versions on the default branch
scope: verctl
completed: "2026-08-17T05:59:57"
disposition: completed
outcome: >-
A pre-push or check fails when a declared manifest version
differs from origin/main outside version-packages.
evidence:
- "github.com/victor-software-house/verctl/pull/9"
- "474cace on main"
- "verctl check --versions plus mise run versions on pre-push"
- "merge-base of HEAD vs origin/HEAD, GITHUB_BASE_REF, main, master"
- "only version-packages exempt; CI does not skip; tests inject Skip"
- id: VER-005
title: "Publish native verctl so mise github: can install it"
scope: release
completed: "2026-08-17T02:21:38"
disposition: completed
outcome: >-
crates.io plus GitHub Release assets for darwin-arm64 and linux-x64.
evidence:
- "crates.io verctl 0.0.1"
- "github.com/victor-software-house/verctl/releases/tag/v0.0.1"
- "verctl_0.0.1_linux_x64.tar.gz and verctl_0.0.1_macos_arm64.tar.gz"
- "mise x github:victor-software-house/verctl@0.0.1 -- verctl --version"
- id: VER-009
title: Keep verctl on ctl-core after the chassis hardens
scope: verctl
completed: "2026-08-17T01:29:52"
disposition: completed
outcome: >-
verctl stays on ctl-core prelude/View/formatdoc. No local help
or color copy comes back.
evidence:
- "ctl-core = \"0.0.3\" on crates.io"
- "ColorMode tables; no path = \"../ctl-core\""
- id: VER-004
title: Open or update the Version PR
scope: verctl
completed: "2026-08-17T01:29:52"
disposition: completed
outcome: >-
One prepare-release PR consumes fragments, commits version files
and CHANGELOG, and deletes the consumed .changeset files.
evidence:
- "github.com/victor-software-house/verctl/pull/7"
- "crates.io verctl 0.0.1"
- "actions/version-pr and actions/publish"
- id: VER-003
title: Write Cargo.toml and package.json versions
scope: verctl
completed: "2026-08-16T20:11:59"
disposition: completed
outcome: >-
prepare --no-pr applies the max bump per declared package to
Cargo.toml and package.json.
evidence:
- verctl prepare --no-pr
- cargo/npm are stock Path drivers (format + keys + after)
- custom drivers via format+keys or read/write shell
- tests/bump.rs (including declared keys and shell driver)
- id: VER-002
title: Render changelogs with minijinja
scope: verctl
completed: "2026-08-16T19:48:01"
disposition: completed
outcome: Defaults are Jinja2 templates rendered by minijinja 2.x.
evidence:
- templates/changelog.jinja and dependency-changelog.jinja
- tests/changelog.rs
- id: VER-001
title: Parse Changesets YAML fragments (quoted and unquoted)
scope: verctl
completed: "2026-08-16T19:48:01"
disposition: completed
outcome: YAML fragments via gray_matter. status and check fail closed.
evidence:
- src/fragment.rs
- tests/fragment.rs and tests/status.rs