verctl 0.0.4

Stack-agnostic version PRs from Changesets-format fragments
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# yaml-language-server: $schema=https://github.com/victor-software-house/qctl/releases/download/v0.0.1/tasks.schema.json
schema_version: 1
prefix: VER
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:
      - prepare writes versions + per-package CHANGELOG and consumes fragments.
      - Version PR is the only version-authority path (CI / --pr).
      - "`[prepare].after` plus stage globs; unexpected dirty files fail."
      - publish is overridable recipes; cargo and bun are stock examples.
      - Lockfile is committed; cargo publish --locked works after bump.
      - Pretty is ctl-core ColorMode tables; tests use --color never.
      - Native assets only when [assets] is set; PR CI stays one runner.
      - Asset jobs are named by target id; cargo/bun inherit stdio.
    notes: >-
      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).

  - 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-017
    title: Pins ride the Version PR, and publish pushes only the tag
    scope: verctl
    outcome: >-
      The released tree carries its own pins, so a consumer that fetches
      by tag or ?ref= gets the version it asked for.
    blocked_by: []
    acceptance:
      - prepare rewrites [[pins]] and the pin files are part of the Version PR commit.
      - A dry run names the files it would rewrite and writes nothing.
      - A pin no configured package claims is left untouched.
      - publish pushes the tag and nothing else; no pin job, no actions/pin.
      - A pin that must name an already-published tarball is documented as ineligible.
    notes: >-
      2026-08-18: started as "the pin phase is an Action" and was wrong at
      the root, caught by Victor: the only thing publish should push is
      the tag. Proof the old shape could not work: qctl's tag v0.0.2
      contains tasks/q/q pinning 0.0.1 and README ?ref=v0.0.1, because
      `verctl publish` cuts the tag in the crate job and the pin commit
      landed after it. A post-release commit can never reach the tree a
      tag names. The Version PR is the right phase for exactly the reason
      it exists: it is regenerated idempotently on every push to main and
      it already holds every other released change. The bootstrap pin is
      the one exception and stays out of [[pins]]: mise.release.toml names
      the tarball a lane installs, so it can only move to a version that
      already exists, and pinning it to the version being released would
      make `mise install --locked` fail before publish could create it.
      That exception costs nothing here, because this repo's own lanes run
      MISE_ENV=dev and build the checkout — a lane that installed its own
      tarball could only ever run the previous release. actions/pin was
      written, proven against a bare remote, and deleted unmerged; the
      scratch-repo evidence is kept only because it is why the timing
      question got asked.

  - id: VER-018
    title: The pin phase materializes templates and runs consumer hooks
    scope: verctl
    outcome: >-
      Anything a release leaves stale is rewritten by declaration,
      whatever file it lives in, without verctl learning that file's
      format.
    blocked_by: [VER-017]
    acceptance:
      - "`[prepare].after` and `.stage` already exist: the pin rewrite needs no second hook vocabulary."
      - A template renders the released versions onto the Version PR commit, and declares its own target inside itself.
      - A named pattern covers what no template can own (a README install line), listed by every file that carries it.
      - "`[[pins]]` structural rewriting keeps working: templates coexist with it, they do not replace it."
      - "mise pin sites are first class: [tools] tables and `#MISE tools = { … }` task headers."
      - "No new Action and no post-release push: the rewrite happens where prepare already writes."
      - The contract is written down before the keys are, and a repo that follows the convention declares no table.
    notes: >-
      2026-08-18, decided: making verctl cover every file format out of
      the box was never the plan and does not scale, so the answer is
      hooks and templates rather than a parser per format. Teaching
      `verctl pin` the `#MISE tools = { … }` header form was proposed and
      rejected for that reason. Templates, overlays, and a plain
      match-and-replace expression are unavoidable in the long run; a
      temporary bridge is acceptable until then. Evidence: qctl has five
      pin sites and `verctl pin` reaches two of them. examples/mise.toml
      holds the [tools] pin and the ?ref=, tasks/q/q line 5 carries a
      `#MISE tools` header, and README repeats the ?ref= plus an install
      hint. All five were hand-bumped to 0.0.2 on 2026-08-18 as that
      bridge, which is the rot this row removes. verctl's own release
      2026-08-18, later: the timing moved to prepare (VER-017), so this
      row is only about reach, not about when. A template renders at
      prepare time with the version being released, which is known then.
      mise.release.lock is out of scope for the same reason the bootstrap
      pin is: it hashes a tarball that does not exist yet.
      2026-08-18, shipped: docs/served-files.md is the contract, and both
      mechanisms are in. Templates live flat in .verctl/templates/ and
      declare path / name / executable as top-level Jinja exports, parsed
      into one schema (serde) and validated at the boundary (garde);
      discovery is the git index, so an untracked template renders
      nowhere. Patterns are named once as [patterns.<id>] and listed by
      every file that carries one, with a declared arity
      (once / many / never / exactly / at_least) that stops the release in
      both directions. verctl dogfoods it: examples/mise.toml is rendered
      from .verctl/templates/mise.toml.jinja, proven by a real prepare in
      a disposable copy of this tree writing 0.0.4 into both the [tools]
      pin and the ?ref=. Left over, VER-020: `check` is not wired to the
      same validation, and nothing yet refuses a file that a template and
      a pin both own.

  - 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: [VER-018]
    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 verctl.toml, 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: [VER-018]
    acceptance:
      - The schemas are generated from the Rust types, so a key that exists in code exists in the schema and no other.
      - "verctl.toml carries `#:schema <url>`; tasks.yaml and changeset fragments carry `# yaml-language-server: $schema=<url>`."
      - 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.

  - 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: [VER-018]
    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; do that one
      first so this is a move, not a fork.

horizon:
  - 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 parse TOML
    scope: verctl
    kind: evaluation
    outcome: >-
      The ci.yml bootstrap costs what reading verctl.toml 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 verctl.toml, 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.

archive:
  - id: VER-012
    title: Block hand-edited versions on the default branch
    scope: verctl
    completed: "2026-08-17"
    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-17"
    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-17"
    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-17"
    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-16
    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-16
    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-16
    disposition: completed
    outcome: YAML fragments via gray_matter. status and check fail closed.
    evidence:
      - src/fragment.rs
      - tests/fragment.rs and tests/status.rs