The manifest
The file, at a glance
The whole top level of a manifest, and where each part is explained:
product: myctl # the product's name
tasks: # the BODIES, each a module:function, declared once
groups: # the COMMAND TREE - build, test, release, deploy, monitor, support
default: dev # the environment used when no env token is given
environments: # the environment matrix
site: # ... and any other product data section a catalogue task reads| what you want to do | where |
|---|---|
| add a command, or a sub-group | The command tree |
| point a command at a body | task:, and what the command adds |
| place the same body twice with different values | Pinning values with with: |
| rename or re-help a command the catalogue placed | Refining a platform command |
| make one command run several others | Aggregates: depends_on |
| run steps side by side | parallel |
| declare the environments a product deploys to | Environments |
| give a catalogue task its values | Product data sections |
| find out whether a name is already taken | The names the kernel has claimed |
| move an old flat manifest onto the tree | The flat form |
Two rules hold everywhere below, and most load errors are one of them: a command never writes
impl: - it names a task: and the body is declared once - and the platform owns which groups
exist, so a product adds to a group and never rewrites its help: or its env_first:.
One YAML file per product. It declares what commands exist, what they run, what they are called and which of them take an environment - and the CLI is assembled from it, so there is no second place where any of that is also true.
The command tree
Groups are slots in the CI/CD loop; commands are the members of a group. The kernel’s catalogue owns
which groups exist - build, test, release, deploy, monitor, support - and a product fills
them:
product: myctl
groups:
build:
commands:
wheel: { task: "pkg:wheel", help: "Build the wheel." }
support:
groups:
git:
commands:
commit: { task: "vcs:commit" }A group node has exactly four keys: help, env_first, groups and commands. Members live under
commands: rather than directly on the node, and that is not tidiness - without it help: would be a
group attribute in one place and a command called “help” in another.
The platform owns the group shape. A product may add commands and sub-groups to a group it
inherits. It may not rewrite that group’s help: or its env_first:. Both are statements about what
the group is, and env_first in particular is load-bearing: a product quietly turning it off would
ungate every command underneath it. The group lock is not a check layered over the merge - it is the
merge. There is one tree, so there is no second way to bring a group into existence.
A group you name is a promise. Declare a group in your own tree and leave it without a single command anywhere in its subtree, and the manifest fails to load, naming the group. A group only the catalogue offers, that your tree never mentions, is simply dropped from the assembled CLI - see the rule and why both halves are needed.
task:, and what the command adds
Every command is an instance of a task: the task carries the body, the command carries the name, the group and the values pinned for this placement. That model, its five refusals and the two name spaces the colon tells apart are a chapter of their own; what follows is only what the manifest file looks like once you have it.
tasks:
wheel:
impl: "orchestrator.cli:build_wheel"
help: "Build the wheel."
groups:
build:
commands:
wheel: { task: "wheel" }A command never writes impl: itself. A bare task: value names a task this manifest declares; one with
a colon names a catalogue coordinate, whose body lives in the kernel.
Pinning values with with:
The same body, placed twice, with different data:
groups:
test:
commands:
unit: { task: "test:gate", with: { name: "unit" }, help: "Run the unit suite." }
system: { task: "test:gate", with: { name: "system" }, help: "Run the system suite." }A pinned parameter is removed from the generated signature and supplied at call time, so it is absent
from the command line entirely: myctl test unit --name system is not a command.
params: is the other half, and it is strictly about presentation: help text, the short flag, the
metavar, the order the declarations render in. The signature - name, type, default - is read off the
body. Declaring the type in YAML as well would state it twice and let the two drift, which is the exact
failure impl: on a command already has.
prune-branches:
task: "vcs:prune-branches"
params:
dry_run: { help: "preview only", short: "-n" }
remote: { help: "also delete merged branches on origin" }Refining a platform command, and replacing one
A command the catalogue already places can be refined in the product’s own tree: change its help:, add
params:, pin a with:. Point it at a different task: and the loader stops, naming both bodies by
their real module:function and offering override: true as the explicit yes. That refusal has its own
rule, and the merge behaviour behind it is in Task and
command.
Aggregates: depends_on
A command with no body, only a plan:
all:
help: "Build then deploy, end to end."
depends_on: [build, up]
stop_on_failure: falseimpl and depends_on are mutually exclusive. A command is either a leaf with a body or an aggregate
that plans other commands - never both. The reason is mechanical: plan steps execute as subprocesses, so
an impl-bearing command that also carried dependencies would re-expand them in the child and break the
run-each-once guarantee.
The plan is a post-order depth-first walk over depends_on, deduplicated by name, so a command reached
along several paths appears exactly once. List order is execution order among siblings, and that is
how one step is made to run before another:
docs:
help: "Write the command reference, then build the website from it."
depends_on: [reference, site]reference writes the page that site reads. Nothing in the list says so except the order, and that is
deliberate: the same fact written twice - once as an order, once as an edge - is a second source that can
drift.
parallel: the one key that suspends list order
Because position is the only statement of order, a command whose dependencies genuinely do not need each other has to say so:
images:
help: "The five device images."
depends_on: [sidecar, frr, vyos, ios, radius]
parallel: trueThose five subtrees then run at the same time, and whatever follows images in its own parent’s list
starts only when every one of them is done. That is the join, and it needs no new key: depends_on
already means “after”, and parallel is the one place it stops meaning it.
It is declared and not derived, and that was measured. A dependency graph already states what must come after what, so deriving parallelism from it - everything unconnected runs at once - looks like the better answer. Over the six manifests simplon can reach, 541 pairs of planned steps have no edge between them and 466 of those would break if they ran together: the orders those manifests rely on are written as list position, not as edges. Deriving would have run a website build before the page it publishes, a packaging step before the thing it packages, and a gradle build inside an image that did not exist yet.
Two things it does not change. A branch is still a chain - parallel applies to the dependencies of the
command that declares it, not to what is inside them - and stop_on_failure still decides what a failure
skips. A step already running when a sibling fails is left to finish rather than killed, because a killed
subprocess exits non-zero and that number cannot be told from the step having failed on its own; the
members of one fan are never skipped for each other, since the declaration says none of them needs
another; and the work after the join is skipped in the ordinary way.
How MANY run at once is the machine’s answer rather than the manifest’s, because the manifest travels
between machines and the number does not: SIMPLON_MAX_PARALLEL sets it, and the default is four or the
CPU count, whichever is smaller.
WHICH RUNNER draws the plan is the machine’s answer too, and asking for the plain one is --no-tui. That
belongs with what a run looks like rather than with what a manifest declares: Running a
command.
Every depends_on entry must name a known, unambiguous command, and the graph must be acyclic. Both are
checked at load, not at run: a dependency naming a command that does not exist is a manifest error, and
it should not wait until the eleventh minute of a pipeline to say so.
hidden: true keeps a command out of every --help listing while leaving it fully invocable - which is
what a plan step named in a depends_on needs, since it must be a real command but need not clutter a
menu meant for a human.
Environments
env_groups: [deploy, monitor]
default: dev
environments:
dev: { backend: local, description: "Local development environment." }
prod: { backend: exoscale, description: "Production." }A group’s env gate is normally the catalogue’s statement, not yours: deploy and monitor carry
env_first: true on their nodes there, so every product gates them the same way and no manifest has to
say it twice. env_groups: is the flat spelling of the same statement, and it is held to the same rule:
listing a group the catalogue already gates is a harmless restatement, listing one the catalogue
declares not env-first is refused exactly as env_first: true on that group’s node is. It is the
manifest’s own statement only for a top-level group no catalogue owns. Everything not gated refuses
the token. See Environments for what the dispatch does with it.
Product data sections
Beyond the command tree, a manifest carries the data its tasks read. This is the seam that makes a catalogue task a promise to three products instead of a convenience for one: the mechanism is the kernel’s, the values are the product’s, and the section is where they meet.
site:
image: "hugomods/hugo:exts-0.148.2"
output: "build/website"
base_url: "https://example.github.io/myctl/"
theme: "github.com/imfing/hextra@v0.12.3"
# source: "docs/site" # omitted: that is the defaultimage and output are required to be declared rather than defaulted. A kernel that named the image
would be choosing a documentation generator on every product’s behalf, and one that guessed output
would hand a recursive delete a directory nobody typed. A missing section fails at load, naming the key,
instead of as a container run against a directory that is not there.
source defaults to docs/site (si#183). docs/ is the documentation root - architecture, specs,
plans and the site all belong in it - so a product that says nothing lands on the convention, and one
that names a path still gets exactly what it named. Omitting the key is the recommended way to write it.
Note the line the default does not cross: source is only ever read, output is handed to
shutil.rmtree before every build, and that is the whole reason one of them has a default and the other
never will.
Two of those values are refused unless they pin a version. image: "hugomods/hugo" means :latest,
which moves under the build; theme: "...@latest" fetches whatever is newest on the day it runs. A
build whose output depends on when it ran is not a build, and a documentation site is committed-to
prose - a generator change rewrites it wholesale. source and output must be plain relative paths
under the product root, for a blunter reason: output is handed to a recursive delete, and
output: /var/tmp/x would delete /var/tmp/x.
It is a default and not a rule, and that was measured rather than preferred. A source: outside
docs/ REFUSED would turn away a product that has done nothing wrong. Measured over the same population
si#159 and si#172 used - every manifest this kernel can reach: its own, the five in
simplon.surface.CONSUMERS, and secure-windows-images - three of those seven declare a site: section
and the three do not agree: cleon at site, biz-cockpit at docs/website, simplon at docs/site. Two of those three would
fail on their first run with a kernel that insisted - an expression rule with no measured cause, which
this platform declines to write (the rules chapter says why).
By that same count the default serves none of the three, because all three name the key. It is for the
fourth: the day your product gets a website, it writes four keys instead of five and lands on the
convention without having had to read this page. Simplon itself is the first consumer - its own manifest
leaves source: out, so the default is exercised by every build docs in the kernel’s own repository
rather than only by a test.
docs:site writes into your working tree, on purpose. A theme declared as a Hugo module means the
build runs hugo mod get <module>@<version> before it builds, and that rewrites go.mod and refreshes
go.sum in the site directory. It is idempotent when the manifest pin and go.mod already agree - the
normal state - but the first build after you move the pin leaves a real diff.
A publishing job that asserts “working tree clean” after building the site will fail on it. That is the
mechanism working, not a fault: the manifest is the single place the theme version is declared, and
hugo mod get is what makes go.mod agree with it. Commit go.mod and go.sum; do not gate on a
clean tree after a site build.
It also writes build/hugo-cache/, which is Hugo’s own cache kept between runs so that a second build
needs no network - the module it resolves and the theme’s remote assets are both answered from it. That
path is the kernel’s, not the manifest’s: it is covered by the same build/ rule a product’s clean
and .gitignore already carry, and a CI job that wants offline builds is the one that should cache it.
The names the kernel has claimed
A top-level key the kernel does not read is the product’s own, and nothing here rules on it. That is the design rather than a gap: the section is where a catalogue task’s mechanism meets a product’s values, and a product that could not invent one would have to ask the kernel for permission to have data. Measured over every manifest this kernel can reach, its own and the six in other repositories, eleven of the seventy top-level keys are exactly that, and every one of them is read by a body in the product’s own repository. Adding a key nobody here has heard of is a supported thing to do.
What was missing is the other half: which names are already taken. The product si#159 was reported
from builds three Windows Server releases, and it learned that releases: already means
{page, from, complete_from} to test:release-notes by reading src/simplon/tasks/releasenotes.py. A
reserved name that can only be found in the source is a trap with a delay on it, so the twenty are
published here and tests/test_manifest_top_level.py holds this table to the kernel in both directions.
| key | read by | what it carries |
|---|---|---|
artifacts: |
release:artifact, release:nuget-*, release:conan-* |
one entry per published artefact: registry, repository, source directory, media type |
assets: |
release:asset |
one entry per file attached to a GitHub release |
carriers: |
the environment selector | one entry per thing an environment is realised ON: its proxmox: node and kind, and the portainer: on it |
build: |
build:cmake-files, build:dotnet-solution |
targets:, the dependency edges between build targets that a directory layout cannot show |
claude: |
support:claude-plugins |
the marketplaces and plugin ids an agent host installs |
default: |
the environment selector | the environment a command targets when no env token is given |
deploy: |
the deploy commands | source:, naming the images: or artifacts: entry a deployment’s versions are looked up in |
doctoolchain_version: |
docs:render |
the pinned docToolchain image tag |
env_var: |
support:environments |
the variable a product’s env-first CLI publishes the active environment into |
generated: |
test:generated |
one entry per committed generated file: its path: and the by: command that produces it |
environments: |
the environment selector | the environment matrix: name, backend, description |
images: |
build:image, release:image |
one entry per container image: registry, repository, Dockerfile, context |
instance: |
the multi-tenant lab | the env var naming the lab instance, and the product’s id-length budget |
lab_egress: |
the lab egress helper | the host interface a lab reaches the outside through |
layout: |
build:toolchain, test:*, docs:acceptance |
where inside your tree a containerised command runs, and which directory holds the tests above the acceptance line |
nexus: |
the nexus commands |
the proxy repositories, the compose file and the container this product runs |
releases: |
test:release-notes |
page:, from: and complete_from:, the three values that gate says what it measures against |
site: |
docs:site |
the pinned Hugo image, where the sources live, where the site is built to |
suites: |
test:* |
the test-level taxonomy: the gates, their order, and which one clears the shared results |
tracker: |
test:walk |
where a refused acceptance step becomes a bug ticket, and the product’s own wording for it: title:, plus kind:, repo:, labels: and preamble: |
workflows: |
release:workflows |
one entry per generated CI file |
A key that is mistyped is therefore not the silent no-op it looks like. Seventeen of the twenty are
named by the reader that wanted them, the moment that reader runs: sietv: instead of site: answers
the 'site' section is missing or is not a mapping, and every other reader refuses the same way, naming
the key it looked for. The three that say nothing say nothing on purpose:
build:- an absent section is the normal case.build cmake-filesandbuild dotnet-solutionrender the build files from the SOURCES, andbuild: targets:only adds the edges the directories cannot show. Abuild:section that exists for some other reason and carries notargets:says the same thing, on purpose - two live manifests have one, and the next section is about them.env_var:- a product that selects its environment by token anddefault:alone has no such variable, and a listing must still work on a manifest that has not adopted the key.layout:- an absent section is what every product in this family has. It arrived with si#250 and its defaults are exactly what the kernel assumed before it existed, so a manifest that says nothing runs the line it ran yesterday. The cost is stated rather than left to be found:layotu:is silent too, and only a key mistyped inside a correctly spelledlayout:is refused.
Both are driven in the test module above, so the silence is measured rather than assumed.
Reading a section, yours or the kernel’s
A product’s own task body reads its own section through simplon.context.section, the same walk the
kernel’s ten readers use:
from simplon import context
ctx = context.current()
packer, blame, got = context.section(ctx.manifest_data(), "build", "packer")
if blame:
raise ValueError(f"{ctx.manifest_path}: `build: packer:` is not declared")It walks a path, because build: is shared and a product that wants its own data under a phase has
to nest (the section above). It stops at the first step that is not a mapping and names that step:
buidl: packer: blames build, so a typo in the outer key never reports the inner one as absent.
got is what the blamed step held - None exactly when it is not declared at all, which is how a
reader tells “the product said nothing” from “the product said the wrong thing”. A section declared and
empty is found, not blamed.
It never raises, and that is deliberate. Each of the nineteen readers above says something specific when its section is missing, and si#159 measured that fourteen of the sixteen it measured already refuse by name and quote the key. An accessor that raised would replace those sentences with one. So it answers the question and the reader keeps its own refusal - which is also what keeps every refusal countable where it is written, in the rules chapter’s census.
build: is shared, and build: targets: is the kernel’s
build is the one name that is a group and a top-level data section at the same time: the
catalogue declares a build group, and build:cmake-files and build:dotnet-solution read their
targets: out of a top-level build: section. The kernel’s own source used to carry a comment saying
the two could never meet. They already do (si#172).
Measured over the same seven manifests si#159 read, two products carry a top-level build: section of
their own and no targets: in it: cleon’s holds bundle:, ant: and site:, and
secure-windows-images’ holds packer: and templates:. Neither is doing anything wrong. The kernel
picked a name that was already taken, and one of those manifests had already written a comment to its
own authors explaining the collision and telling them not to add a targets: key.
The rule, so nobody has to read the source for it again:
build:is yours. Declare it, put what you like in it, and nothing here refuses it. Refusing it is what si#159’s measurement rules out - eleven of the seventy top-level keys across those manifests are read by product task bodies in repositories this kernel cannot see, so a rule over the top-level namespace refuses live products on its first run.- The kernel reads exactly one key out of it,
targets:, and rules on nothing else in there. - An absent
targets:means what an absentbuild:means: the source tree is the whole declaration. That is not a fallback, it is si#102’s design - the directories say what the targets are, andtargets:only adds the dependency edges they cannot show. Driven: a product carrying either of those two real sections and placingbuild:cmake-fileswrites exactly the files a product with nobuild:section writes. - Inside
build:, the wordtargetsis the kernel’s. If your own build vocabulary has targets - cleon’s Ant block is one rename away, withgenerate_targets:,compile_targets:andpackage_targets:- call yours something else. Atargets:of the wrong shape is refused by name and tells you to rename; it is not silent.
images: - the container image
build:image and release:image read one entry of this section, pinned per command with
with: { name: ... }, because a product may build more than one:
images:
app:
registry: ghcr.io/example
repository: myctl
dockerfile: Dockerfile
context: .
tag: latest # optional; `--tag` overrides it
build_args: # optional; yours, on top of the two the kernel derives
PYTHON_VERSION: "3.12"The first four are required, registry included even if you only ever build locally: the reference the
build tags is the reference the release pushes, and an unqualified name is one docker resolves against
Docker Hub. dockerfile and context must stay under the product root - context: / would stream your
whole filesystem to the daemon as a build context.
VERSION and REVISION are passed on every build, and you do not declare them. The kernel derives
them from your checkout - git describe --tags --always --dirty and git rev-parse HEAD - so the
image built on a laptop carries its provenance exactly as the one built in Actions does. Declare either
name in build_args: and yours wins; a Dockerfile that declares neither ARG ignores both at no cost.
The two are missing, with a warning, only when there is no checkout to ask - an exported tarball - and
your Dockerfile’s own ARG VERSION=dev then stands rather than being overwritten with a placeholder.
git describe falls back to the short commit. If you want the
release tag in the label, check out with fetch-depth: 0.release:image asks the registry whether the tag arrived before it reports success. A push whose
result nobody reads is the same defect as a report nobody reads, and this project has shipped that twice.
The question goes through oras - the tool the kernel provisions anyway - and not through a docker
client, because docker manifest inspect answers out of ~/.docker/manifests/ and will confirm a tag
that only ever existed on your machine.
If registry: does not name ghcr.io, no GitHub token is minted for it. The push then uses whatever
credential your own docker login <host> stored, and a rejection says so instead of pointing at
gh auth refresh, which would mean nothing on someone else’s registry.
releases: - where the notes live, and from when they are complete
test:release-notes reads this one. It is the smallest product-data section in the kernel, and that is
the point of it: the rule it feeds needs no product knowledge at all, so what a product owes it is three
values and nothing else.
releases:
page: "docs/site/content/when/releases.md" # where the notes live, under the product root
from: "0.4.0" # the first release that must have a section at all
complete_from: "0.5.0" # the first section that must name every ticket in its rangeThe gate then answers four questions out of git tag, git log and the page’s own ## X.Y.Z headings:
is every release at or above from written up at all; does the page promise a version nobody can
install; does every merge in a documented range name a ticket in its subject; and does every section
from complete_from on name each of them.
Nothing reads the prose. What a change meant is knowledge no tool has. The whole claim is that a number appears somewhere in the section, which is deliberately the weakest claim that catches the failure this exists for - a release section that describes the pull request it was written in rather than the release it names.
The two floors, and why the second is a floor rather than a list. from is where notes begin:
earlier releases have their tags and their commits, and writing them up now would mean reconstructing a
record from memory. complete_from is where completeness begins, and the gap between the two is the
excused set. An exemption list would be the thing that grows quietly, one entry at a time, each looking
justified on its own; a second floor can only ever be raised in public. A product that excuses nothing
writes the same number twice, and a complete_from below from is refused, because it would demand
completeness of a section the same manifest says may be absent.
The spelling is the page’s, not git’s. 0.4.0, three parts and no v. The tag carries the v
and the heading does not, so from: "v0.4.0" is refused rather than helpfully stripped - it would
otherwise be a floor that matches no section at all.
The range is <last tag>..HEAD of the state being checked, and which state that is depends on the
event. A push to your default branch has the merge that just landed as HEAD, so a pull request’s
own ticket is in range from the second it merges. A pull_request run checks out refs/pull/N/merge -
your branch as merged with the base - so a branch green on its own tip goes red the moment the base
moves. Both are the gate working; neither used to be said. Every run now prints what HEAD resolved
to, and names GitHub’s own merge in as many words when that is what it found.
A pull request never has to name its own PR number. GitHub’s Merge pull request #N from <branch>
is a statement about the act of merging, not about the work, so the gate reads the commits that merge brought
in instead. The number the notes have to carry is the ticket number in your own commit subjects - one
you had before the branch existed. Without that rule a release-notes PR would demand a note about
itself, and a follow-up PR would bring its own number too: the regress has no floor.
It is offered, not placed. A product with no release notes never sees the command, and one that
declares this section places it in one line under test. A checkout with no tags is diagnosed as the
checkout - actions/checkout fetches no tags unless you ask - rather than blamed on the page, and a
run that ruled on nothing is red rather than green.
workflows: - the CI files, generated from this same manifest
The manifest has always been meant to have two outputs. It assembles the command line, and - with
support:workflows - it writes the GitHub workflows that call it.
workflows:
ci:
note: |
Every push and every pull request, and it is the SAME command a developer types.
on: [push, pull_request]
jobs:
self-build:
runs-on: ubuntu-latest
python: "3.12"
steps:
- command: test all
- command: build wheelThe line that matters is command: test all. It is not a string that happens to look like a command -
it is resolved against the command tree above before anything is written, and what lands in the file
is ./myctl.sh test all. Rename that command and generation fails, loudly, in the same commit. Today,
in a hand-written workflow, a renamed command leaves a run: line calling something that is gone, and
the first anybody hears of it is a red runner.
Everything the kernel cannot know stays yours and is carried through untouched, at the level GitHub puts it:
| level | carried |
|---|---|
| workflow | permissions:, concurrency:, defaults:, env:, run-name: - and anything else GitHub adds |
| job | permissions:, environment:, needs:, concurrency:, if:, strategy:, timeout-minutes:, … |
step (beside command:) |
name:, if:, env:, id:, continue-on-error:, working-directory:, … |
The levels are not interchangeable, and getting them the wrong way round is the mistake this table
exists to prevent: environment: and needs: are a job’s, defaults: and run-name: are a
workflow’s. Neither list is enumerated in the kernel - the keys are simply passed through, because a
kernel that policed GitHub’s schema would be wrong the week GitHub extends it.
A step is the exception, and deliberately: it has exactly one body, so a key that is neither a
modifier nor a body is refused rather than carried. Two bodies - command: beside uses: or run: -
are refused for the same reason.
flow: on a workflow rolls a release out to your environments, in order. The kernel writes one job
per stage:
environments:
test: { backend: portainer, carrier: haus, stack: app-test, deploys: latest }
prod: { backend: portainer, carrier: haus, stack: app-prod, deploys: "1.4.0" }
workflows:
rollout:
on: { release: { types: [published] } }
runner: { kind: github-ubuntu }
flow:
- to: test
- to: prod
approval: trueTwo declarations, because they answer two questions. The environment says what it receives, with
deploys: — latest for the newest published version, or a pinned tag. The flow says in which order
environments are reached and which stage a person releases. A staging instance wants the newest
publication and a production one wants a chosen tag, and that is true however the rollout is triggered;
putting the version in the flow as well would give one truth two homes.
What comes out is deploy-test and deploy-prod, the second with needs: deploy-test — chained rather
than merely ordered in the file, because GitHub runs jobs in parallel unless told otherwise and a
rollout whose order lived only in the emitted order would reach production and staging at once while
looking correct.
approval: true gates nothing here: it gives the job GitHub’s own environment:, which is where an
account configures required reviewers, wait timers and the secrets that stage may see. Who may approve is
an account setting, and it is the only half that can be changed without a commit.
deploys: in your manifest, so the kernel writes deploy up --version 1.4.0 with a value nobody outside
the repository chose. The obvious alternative — a workflow_dispatch input interpolated into the command
— is the classic Actions script injection, in a job holding deployment credentials, emitted by the
kernel on your behalf. A product that wants a person to choose the version at dispatch can still do it,
in a jobs: block of its own, where the hazard is visible in its own file.Your own jobs stand beside it. A workflow may declare flow: and jobs: together — the generated
names are stable, so a notification job hangs needs: deploy-prod on one. And a step may now hand a
command a parameter:
- command: deploy up
params: { version: "1.4.0" }The parameter name is checked against what the command declares, which is si#40’s join one level in:
a renamed parameter breaks generation instead of becoming a run: line that fails on a runner three
minutes into a job.
derive: on a step writes the pipeline the command tree already implies (si#267). A step that
declares it is not one step but a placeholder for however many the named groups carry:
jobs:
self-build:
runner: { kind: self-hosted-debian, labels: ghr-8 }
steps:
- derive: [build, test]Every leaf of each named group that may run with nobody watching becomes a step, in the order the
manifest declares it. Leaves rather than aggregates, because a GitHub job stops at its first red step -
emitting test all would collapse four verdicts into one and lose the order its members were written
in. An aggregate is skipped without comment, because nothing is missing from the file; a leaf that is
skipped is named in the file, with the reason, since a reader wondering where test walk went is
standing in the workflow and not in the kernel’s source.
Which leaves may run is the unattended: flag from task and command, declared
in the platform catalogue once for every product that imports the coordinate. A leaf that says nothing
is refused by name rather than guessed at.
A derived step stands beside your own, and that is the reason it is a step rather than a key on the job. A product that wants the derived pipeline plus a Check Run reporter writes both and decides where the seam falls:
steps:
- uses: actions/setup-node@v4
- derive: [build, test]
- command: release imageA job-level derive: would have had to refuse steps: beside it — and that refusal would have been an
expression rule, forbidding something a product legitimately wants and sending it back to writing the
whole file by hand, which is the work this feature exists to remove.
runner: names a KIND of machine, and the kernel supplies what follows from it (si#267). This is
the answer to a day that was spent twice: moving one repository’s CI to a self-hosted runner meant
learning that actions/setup-python never installs Python, that a container job cannot be the thing that
provisions docker, and that DELIVERY_DOCKER_BOOTSTRAP=1 exists — all of it already written down, in a
comment in another product’s manifest.
jobs:
self-build:
runner: { kind: self-hosted-debian, labels: ghr-8 }| kind | runs-on: |
setup-python |
environment |
|---|---|---|---|
github-ubuntu (the default) |
ubuntu-latest |
yes | — |
self-hosted-debian |
yours to name | no | DELIVERY_DOCKER_BOOTSTRAP: "1" |
The label stays yours: ghr-8 names one machine and no kernel can guess it, and
[self-hosted, Linux, X64] is not an answer — it describes every self-hosted Linux machine the account
will ever register, so the day a second one joins, the jobs are shared out with nothing saying so. A kind
that carries no label of its own therefore refuses until the job names the machine.
What the kind buys is the rest. A python: pin on a kind that cannot honour it is refused at
generation, because a pin that cannot be honoured reads as a guarantee and is a wish; the kind’s own
environment is merged into the job’s, and a variable the two set differently is refused rather than
resolved. The kind’s one-line reason is written into the generated file, so a reader who finds
runs-on: ghr-8 beside DELIVERY_DOCKER_BOOTSTRAP: '1' does not have to find the kernel to learn why
they belong together.
runner: and runs-on: answer the same question, so declaring both is refused. Neither is
deprecated: runs-on: is the whole of what a product needs when its machine has nothing to teach
anybody, and a new kind is added to the kernel’s table once, for everybody, rather than described again
in each manifest.
runs-on: may be a label, a list of labels, or GitHub’s group:/labels: mapping - all three of
GitHub’s shapes, carried through as written. A list is the documented way to reach a self-hosted runner,
because one label is rarely enough once an account has more than one machine:
jobs:
template:
runs-on: [self-hosted, windows, vmware]Until 0.16.0 the value was coerced with str(), so that list came out as the single label
"['self-hosted', 'windows', 'vmware']" - valid YAML, valid GitHub syntax, and a runner that cannot
exist, so the job queued for ever with no error anywhere (si#266). A value GitHub has no reading for at
all - a number, a bool, a list with something other than a label in it - is refused naming the line.
Whether a tag publishes is your statement, so a workflow that declares no on: is refused rather
than given a default. A step the kernel has no business modelling - pypa/gh-action-pypi-publish, an
upload, a shell script that reports something - is written out verbatim beside the resolved ones.
A command step keeps its modifiers, which is what lets it stay a command step:
steps:
- name: The system gate
command: test system
- name: Publish the report
if: always()
command: test reportWithout that, every step carrying a name: or an if: would have had to be written as a verbatim
run: line - the hand-typed, unchecked string this section exists to abolish. Measured across six real
products: 34 of 41 command-invoking steps carry one of name:, if: or env:.
A command step that declares no name: shows its run: line in the Actions UI, and that line is
./myctl.sh test all - the same string you type in a checkout. The kernel does not invent a name for
it; say name: if you want different words.
Two things are the kernel’s, and both are settings you should not have to remember. The checkout is
emitted with fetch-depth: 0, because actions/checkout defaults to a shallow clone that carries no
tags - so anything deriving a version from one gets a wrong answer silently rather than an error; and
python: becomes a setup-python step, or nothing at all if you declare none. A job that wants neither
says checkout: false and writes its own.
Your comments survive. note: may sit on the workflow, on a job or on a step, and is written out as
a comment in that position. That is a requirement rather than a nicety - a real workflow carries measured
values and the reasoning for absences, and a generator that dropped them would make the file worse than
the one it replaced.
on: is a boolean in YAML 1.1. yaml.safe_load("on: [push]") gives you a mapping keyed by True,
not by "on". Write the trigger the natural way here - the loader reads both spellings - but if you ever
parse a workflow yourself, look under True, or you will search a section you never had and read the
miss as an answer.--check, and the file nobody owns
myctl support workflows --check reports drift and returns 1, so one command is both a pre-commit
hook and a CI step. It also returns 1 for a file in .github/workflows/ that no entry names. That
second case is the reason the section exists: a workflow nothing generates and nothing declares looks,
from the directory, exactly like one somebody maintains - and it keeps running long after it stopped
meaning anything.
There are two ways to answer it, and the second is a real answer rather than an escape hatch:
workflows:
release:
handwritten: >-
two-thirds prose and two multi-line shell scripts; declaring it would move that work
rather than remove itA declined workflow is never written to, and it is named with its reason on every run - because “this one is hand-written” is easy to keep believing after it has stopped being true.
The declaration has to keep being true, too: if the file it names is not there, --check returns 1
and says so. A name with nothing behind it fails exactly the way a file nobody names does - by looking
accounted for.
Other sections work the same way: suites: is the test-level taxonomy a product’s own test tree
defines, environments: the deployment matrix, nexus: and claude: the data their respective tasks
read. A task that needs a section it does not find fails on its first line, which is why such tasks stay
tasks in the catalogue rather than being placed as commands for everybody.
layout: - where your tree sits
The kernel generates against two facts about your directories, and before si#250 it guessed both.
layout:
build_root: kernel # default: "" - the product root
tests: tests # default: testsbuild_root: is the question workdir: looks like it answers and does not. workdir: is the path
your tree is mounted at inside the container - toolchain:run writes
-v <product root>:<workdir> -w <workdir> - so naming a subdirectory there relocates the whole tree
rather than descending into it. build_root: descends, and the mount stays the product root, which a
build that walks up to find its fixtures needs:
without layout: -v /home/dev/firn:/work -w /work gradle assemble
build_root: kernel -v /home/dev/firn:/work -w /work/kernel gradle assembleThat trap cost a real adoption: the Java profile scaffolds gradle assemble, the product’s Gradle root
was one directory down, and the scaffolded command was silently wrong.
tests: is the directory above the acceptance line - where test report writes its merged report
and where docs acceptance reads scenarios. The spelling the kernel writes is tests, decided once
because both were in live use and nothing had chosen. A product whose root directory is test/ says so
here in one line, instead of working around it in every command that touches a path.
Nothing here is prescribed, and that is a measurement rather than a preference. Across this family,
six products have six layouts - one is an Eclipse plugin tree, one has no src/ at all. A kernel that
stated one layout and held products to it would refuse five of the six. So this section lets a product
say what the kernel was otherwise guessing; it never rules on a tree.
An absent section is the normal case, and its defaults are exactly what the kernel assumed before it
existed - so a manifest that says nothing runs the line it ran yesterday. The cost is stated rather than
left to be found: a section name mistyped as layotu: is silent, and only a key mistyped inside a
correctly spelled layout: is refused.
carriers: - what an environment is realised on
An environment says where it deploys with backend:. It says onto what by naming a carrier - and
a carrier is named once and pointed at many times, because one Portainer serves several applications and,
on the same instance, several environments.
Both halves of a carrier are optional, and each absence is a statement. proxmox: is the machine the
kernel makes; portainer: is what it installs on it. A carrier with only proxmox: is a machine created
and deliberately not configured (si#258). A carrier with only portainer: is a Portainer somebody else
built - it has been running for months, the kernel did not make it and will not, and all this manifest
needs is where it answers (si#280):
carriers:
theirs:
portainer:
url_from: PORTAINER
insecure: truedeploy carrier refuses that one by name, because there is no machine for it to make, and it says so in
terms of the alternative rather than of a fault. A carrier declaring neither half is refused at load:
each absence says something, both at once say nothing that any command can act on.
carriers:
hausportainer:
proxmox:
endpoint: https://10.0.0.6:8006/
token_from: PROXMOX # a PREFIX -> PROXMOX_API_TOKEN; never the token itself
insecure: true # only for a self-signed certificate; the default verifies
node: pve1
kind: lxc # or vm - the choice is per environment
template: local:vztmpl/debian-13-standard_amd64.tar.zst
storage: local-lvm
cores: 2 # these three may be left out
memory: 2048
disk: 4
ssh_key: "ssh-ed25519 AAAAC3Nz... you@host"
portainer:
url_from: PORTAINER # a PREFIX, never a value
endpoint: 1 # Portainer's own id; 1 is what a single-host install has
insecure: true # Portainer makes its own certificate on first start
environments:
test:
backend: portainer
carrier: hausportainer
stack: myctl-test
repository:
url: github.com/you/myctl
compose: deploy/docker-compose.yml # default: docker-compose.yml at the root
prod:
backend: portainer
carrier: hausportainer # the SAME carrier
stack: myctl-prod
repository:
url: github.com/you/myctl
ref: release/2.x # default: main; `refs/tags/v2.1.0` deploys a tag
compose: deploy/docker-compose.yml
credential_from: GIT # a PREFIX -> GIT_USER, GIT_PASSWORD; omit it for a public repository
required: # must have a value, or the deployment stops
- COCKPIT_DATA_DIR
- BACKUP_DIR
- HTTP_BIND
optional: # travels when set, absent when not
- SMALLINVOICE_CLIENT_SECRET
- APP_TITLEThe sizes you leave out are the ones the community Proxmox helper script uses - 2 cores, 2048 MB,
4 GB - read off ct/docker.sh rather than invented, so a carrier that says nothing gets what that tool
would have given it.
Two secrets are read from the environment and neither has a field here. token_from: PROXMOX means
PROXMOX_API_TOKEN; url_from: PORTAINER means PORTAINER_URL, PORTAINER_TOKEN and - because
Portainer needs an admin account at start or it locks itself after five minutes - PORTAINER_PASSWORD,
which must be at least twelve characters.
ssh_key: is a PUBLIC key and belongs in the file. It is not a secret, and putting it here is what
makes the carrier fully described by the manifest. The private half never appears.
Two things the kernel sets and you cannot get wrong: an unprivileged LXC will not start a Docker
daemon without nesting and keyctl, so they are not fields - they are what a carrier is, set on
every container the kernel creates. Whoever has set them by hand once has also forgotten them once.
insecure: must be true or false, never a string. Every non-empty string is truthy, so
insecure: "no" would mean the opposite of what it says. It is the one value here that is refused
rather than read leniently.
A lower-case prefix is accepted. token_from: proxmox reading proxmox_API_TOKEN is legal on every
platform the kernel runs on, and a rule against it was written and struck: it would have refused a
manifest that works.
Why a section rather than more keys on each environment. Written into the environments, the carrier above would stand in the file twice - and the two could drift apart with nothing comparing them. Stated once, they cannot.
carrier: is not a second backend:. They answer different questions and both are needed:
backend: says who deploys, carrier: says onto what. The backend stays what it has always been - the
one axis a product extends by registering an implementation.
repository: is where Portainer pulls the compose document from, itself. Which means Portainer needs
read access to it; the orchestrator does not. It is a block rather than a bare URL because the first real
consumer’s compose document is not at the repository root, and compose: is the only place that can say
so. credential_from: names a PREFIX like every other credential here, and the credential is sent with
every deployment rather than stored in Portainer - so a stack somebody opens in the Portainer UI carries
no usable read access to your source.
backend: portainer needs no product code. It is the one backend the kernel ships, because the whole
chain under it is already the kernel’s: deploy carrier builds the Portainer, carriers: describes it,
and this section points at it. A product that registers its own implementation under that name still wins.
portainer: insecure: is the same value as the Proxmox one and is there for a measured reason.
Portainer generates its own certificate on first start, so a carrier the kernel has just built answers
only with verification off. Give it a real certificate and leave this out.
deploy up does not report success on a deployment that did not come up. Portainer answers 200 to
accepting a stack, not to running one - a stack whose compose file does not exist is accepted and then
fails - so the deployment is read back until Portainer says it is up, and three outcomes are told apart:
it is up, it failed (with Portainer’s own sentence), or it is still deploying when the wait ran out.required: and optional: name the variables a deployment is given. The values come from the
environment the deploy command runs in, on every deployment - not from a copy maintained by hand in
Portainer. Portainer stores them either way; the question is only whether its copy is an image something
refreshes or an original nobody does, and two masters of one set of values drift.
Two lists, because “travels” and “may not be empty” are two statements. A name in required: must
have a value when the deployment runs. A name in optional: travels when it has one and is left out when
it has not - and left out, not sent as an empty string, because to a document that writes ${X:-} those
are the same today and the day they differ the kernel would have decided for it. A name in neither list
does not reach the deployment at all, so the two lists are the whole answer to “what goes over”.
It was one list first, and the second one is not symmetry - it is a case one list could not say. A real
product writes ${SMALLINVOICE_CLIENT_SECRET:-} where empty means “not connected”: the integration is
deliberately optional. In required: the product becomes uninstallable without a Smallinvoice account; in
neither list the secret never arrives and the integration is not optional but impossible. A name in both
lists is refused rather than ranked.
An empty value fails the deployment. It is the one place a consuming product asked for more strictness than was offered, and the case is worth the refusal:
# in the compose document
- "${COCKPIT_DATA_DIR:-${HOME}/.biz-cockpit}:/data"A missing value does not make compose fail - it falls back, and the bind mount lands in the carrier’s
/root/.biz-cockpit instead of /srv/biz-cockpit/prod. The container writes happily, the deployment
looks green, and the database sits outside everything the product’s own backup knows about. An instance
that runs and is not backed up is this project’s recurring defect exactly: green because nobody looks. So
every missing value is named, all of them at once - repairing eight variables one run at a time is the
kernel’s work handed to an operator.
A name that is not in the list does not reach the deployment, which is what keeps the manifest
readable as the answer to “what goes over”. And a manifest names variables here, never holds one:
TOGGL_API_TOKEN=4c2b9f is refused rather than read as a name with an equals sign in it.
The version reaches the stack as SIMPLON_VERSION. A compose document writes
image: ghcr.io/acme/app:${SIMPLON_VERSION}, so deploy up --version 1.4.0 deploys 1.4.0 rather than
whatever the document happened to name. It is the kernel’s variable and required: may not claim it -
two answers to one question, and nothing would print which had been used. local is refused here:
Portainer clones, and there is nothing on your machine for it to clone.
url_from: PORTAINER names the
prefix, so the URL is read from PORTAINER_URL and the token from PORTAINER_TOKEN. A key this section
does not take is refused, not ignored - because a parser that skipped what it did not recognise would
let a token: ghp_... sit quietly in a committed file, which is a thing that has already happened once
in this family.An absent section is not a refusal. A product that deploys nowhere yet has no carrier to describe,
and every field above defaults to empty. The section becomes required the moment an environment points at
one - and then a carrier: naming nothing declared is refused, and told which carriers exist.
portainer: is optional, and leaving it out is a statement. A carrier that declares only proxmox:
is a machine the kernel creates and does not configure:
carriers:
lab:
proxmox:
endpoint: https://10.0.0.6:8006/
token_from: PROXMOX
node: pve-2
kind: vm
template: local:vztmpl/debian-13-standard_amd64.tar.zst
storage: local-zfs
ssh_key: "ssh-ed25519 AAAAC3Nz... you@host"deploy carrier then stops after the machine exists and says so - “exists on pve-2 and nothing was
installed on it”. What runs there is your own to deploy, through a backend you register. Until si#258
the portainer: block was required, which made carrier and Portainer host the same word: an
apt -> Docker -> Portainer playbook ran at the end of every run with no branch in it, so a product that
wanted a machine got a workload it never asked for - and on a machine without apt, a red run.
backend: portainer pointed at such a carrier is refused by name, because that backend has nowhere
to put its stack. The refusal names the carrier and both ways out.
deploy: - which version a deployment is deploying
A deployment names its version, and it has three kinds of answer:
./myctl.sh prod deploy up --version 1.4.0 this exact published version
./myctl.sh prod deploy up --version latest the newest published version
./myctl.sh prod deploy up --version local the current code base, built nowThree values for three meanings, rather than one value that sometimes means a lookup and sometimes a
build. local touches no registry at all - that is the point of it - while the other two resolve against
one, and a version the registry does not serve is refused before anything starts:
ghcr.io/acme/demo-app does not serve ‘9.9.9’, so nothing was deployed. Published versions are what
latestresolves against;localdeploys the current code base without needing one
Asking for nothing is refused too. “Whatever is newest” is a choice somebody makes, and a deployment that made it silently would be exactly the accident this section exists to remove.
The section points at an entry you already have, rather than naming a registry a second time:
images:
app: { registry: ghcr.io/acme, repository: demo-app, dockerfile: Dockerfile }
deploy:
source: { images: app } # or { artifacts: <name> }Everything about where comes from that entry, so there is one place a registry is written down. An
entry that cannot serve as a deployment source says so by name - one that is a bare string rather than a
mapping, or that carries no registry: - and the message lists the keys it does carry, because the next
question is what to add.
dev|test|uat|prod choose where a deployment goes, not where the artefact
comes from. Build once, deploy often - and a promotion is only provable if the number that went to test
is the number that goes to prod.generated: - the files that must be what their command produces
A generated file you commit carries one fact twice: the source it comes from, and the bytes in the tree. They drift, and the drift is visible only to somebody who regenerates - which is exactly the person who was not going to.
generated:
completion: { path: deploy/completions/myctl.bash, by: support completion }
reference: { path: docs/site/content/with-what/commands.md, by: docs reference }./myctl.sh test generated runs each by: and fails if the file it produced is not the file that was
committed, naming which one and what to run. by: is the command a person would type, without the
launcher.
A file git does not track is a failure, not a pass. The obvious implementation of this gate is
git diff --exit-code, and over an untracked path that exits 0 - so the artefact that was never
committed at all, which is this mistake in its most complete form, would come back green. Each entry is
checked for being tracked before anything is regenerated.
And a regeneration that fails is not freshness either. A command that could not run leaves the file exactly as it was, so the diff is empty. The gate reads the return code, because “regenerated, and it matched” and “did not regenerate, and nothing changed” are two different things.
An API contract is one of these, and that is the whole of the answer (si#240). A product that exports
its OpenAPI document and commits it has a generated file like any other - so the export is a build
command the spec entry of the python and java toolchain profiles scaffolds, and the checking is this
section:
generated:
contract: { path: api/rest/openapi.json, by: build spec }Nothing about that is REST-specific, and nothing about it is a language’s. The question had been asked
twice in this family before it was answered once: a Python product wrote git diff --exit-code over its
contract and called it its CI staleness gate, and this kernel wrote a suite for its own shell completion.
Both are the same six lines - and the first of them is the version that passes green over a contract
nobody ever committed.
tracker: - where a refused step becomes a ticket
test:walk puts a person through the product’s .feature files one step at a time. When they refuse a
step, that refusal is a bug report somebody has to write - so the kernel writes it, and this section is
what it needs to know:
tracker:
kind: github # the only one implemented; the default
repo: "" # "owner/name", or "" for the checkout's own remote
labels: ["bug"]
title: "Acceptance refused: {scenario} ({version})"
preamble: >
A person walked the acceptance scenarios and refused this step. The verdict is a
human judgement rather than a measurement.title: is the one key with no default, on purpose: a kernel-invented title is the first thing a
maintainer reads and the last thing the kernel should be guessing. It may name product, version,
revision, address, feature, scenario, step, step_number and step_total; a field that is not
on that list is answered with the list.
preamble: is the product’s own wording, put above the evidence the kernel writes underneath - the
step, the version, the revision, who walked it, when, and what they said.
A second walk that refuses the same step again does not open a second ticket. Each one carries a
simplon-walk-id: line in visible prose, and the search for it is what makes the walk idempotent - the
line is prose rather than an HTML comment so the person reading the ticket can see why, too.
title:, a
kind: this simplon cannot reach, a manifest that will not parse - is said out loud and names what it
wanted, and then the walk carries on: the refusals stay in the record and the run is still red. That is
why none of it raises. The sitting has a person’s afternoon behind it, and a manifest typo must not throw
it away.The flat form, and how to leave it
You may meet an older spelling, in a manifest that predates the tree: a body written straight onto a
command, and catalogue tasks placed through an import: section plus a coordinate-keyed tasks: entry.
groups:
build:
wheel: { impl: "orchestrator.cli:build_wheel", help: "Build the wheel." }
import:
delivery: [docs]
tasks:
docs:reference:
group: buildThat form was abolished in 0.4.0. A manifest written that way no longer loads, and the loader says so by name rather than letting it die further down as a missing key on some node:
this manifest is written in the flat command form, which no longer loads: the form was abolished in
0.4.0 and this kernel is past it.
What says so here: group(s) 'build' name commands directly, with `impl:` on them; task(s)
'docs:reference' are keyed by a platform coordinate; an `import:` section makes catalogue coordinates
available.
What it becomes:
- a command is an INSTANCE of a task: declare the body once under `tasks:` and let the command point
at it with `task:`, under `groups: <group>: commands:`
- a catalogue task keeps its body in the kernel - the command names the coordinate
(`task: "<namespace>:<name>"`) and copies nothing
- the catalogue's own commands arrive by merging its tree, so `import:` has nothing left to do -
delete the section
This migration is documented at
https://marcozwyssig.github.io/simplon/how/manifest/#the-flat-form-and-how-to-leave-it
and the shape it leads to at
https://marcozwyssig.github.io/simplon/how/manifest/
Nothing here rewrites the file for you: the sections have to be edited by hand, which is also the only
way your comments survive the move.The manifest above becomes:
tasks:
wheel: { impl: "orchestrator.cli:build_wheel", help: "Build the wheel." }
groups:
build:
commands:
wheel: { task: "wheel" }
reference: { task: "docs:reference" }(Until 0.11.0 the refusal also printed that block for you, rendered from your own manifest. The renderer went in si#85: no manifest that installs this kernel had been on the flat form since 0.4.0, so it was some 250 lines describing the manifest’s shape a second time, where nobody reading was left to notice it drifting - and si#56 had just found a property of it that was mis-documented from the day it was written. This page is the source that is maintained.)
Seven things to know while you convert:
- An aggregate crosses unchanged. A command with
depends_on:and noimpl:was never a body, so there is nothing to move out of it. - A shared body becomes one task. Two commands that spelled out the same
impl:are one template with two placements - which is the point of the form. Do not carry the duplication across. - A name collision needs two task names.
build buildanddeploy buildare two different bodies under one command name. That is legal in the tree and impossible in one flattasks:block, so one of the two bodies has to be declared under a different task name; the commands keep the names they had. - A name the catalogue places needs
override: true.support installis your body under a name the platform already uses, so the merge demands you say you mean it. Where your placement names the platform’s own body, it is a refinement and needs no key. - A
tasks:entry withgroup:keeps its body and loses the key. Its body is already where the tree form wants it; what makes it flat is that it places its own command. Deletegroup:, and add the command undergroups: <group>: commands:pointing at the task withtask:. Leaving the key behind is the one case where a manifest fixed by eye is refused a second time. - A coordinate-shaped name with its own
impl:is your body, not the platform’s.docs:site:with noimpl:names the kernel’s task and copies nothing;docs:site:carrying animpl:was never a placement of the platform’s body at all - it is yours under a name that looks like theirs. Keep theimpl:, rename the task to a bare name, and point the command at that. - Your comments are yours to carry. Nothing rewrites the file for you any more, which is the one respect in which this is easier than it was: a generated block was correct YAML carrying none of the reasoning the file it replaced carried, and a consumer whose manifest held forty lines of it kept them only because somebody looked.
What no conversion can fix is an old manifest that says something the tree form does not allow at all - a group the platform’s tree does not declare, a coordinate placed outside the group its namespace names, a name that is an aggregate for you and a task-backed command in the catalogue. Those are refusals of your manifest rather than of its spelling, and each names its own way out.
What you lose is the import: section, and you lose nothing with it: the catalogue’s own commands
arrive by merging its tree, and any other coordinate is named directly by the command that wants it.