Documentation
Guides and reference material for secure, contract-driven infrastructure templates.
ainfra is the infrastructure provisioning layer for projectious.work. It
combines strict contracts with a thin, local wrapper around OpenTofu and
Ansible:
ainfra apply → provisioned target + non-secret output contract
→ aibox deploys workloads to that target
→ processkit reconciles workspace content
The project provisions targets. It does not build workload images, install
Kubernetes or processkit, deploy aibox fleets, or hide OpenTofu and Ansible
behavior.
Find your path
Core promises
- Inputs are validated before infrastructure mutation.
- Apply identifies the exact reviewed plan.
- Destruction requires the exact reviewed destroy-plan ID.
- Credentials enter through references and child-process environments.
- Ordinary output contracts contain references, never secret values.
- Provider, image, automation, and scanner versions are pinned.
- Every automated gate is available locally.
1 - Getting started
Install the toolchain and exercise ainfra safely.
Install a released binary with the Installation guide, then continue with the
Quickstart. The
Quickstart validates the project before showing the plan, apply, and teardown
lifecycle.
1.1 - Installation
Install a verified ainfra release without root access.
The supported installer downloads only from the canonical GitHub release,
verifies the release checksum before extraction, and installs to
$HOME/.local/bin by default. It never invokes sudo.
curl --proto '=https' --tlsv1.2 --fail --location \
--proto-redir '=https' \
https://github.com/projectious-work/ainfra/releases/latest/download/install.sh \
-o /tmp/ainfra-install.sh
sh /tmp/ainfra-install.sh
Review a downloaded installer before running it when that is required by your
local security policy.
To install a specific stable version:
AINFRA_VERSION=0.1.0 sh /tmp/ainfra-install.sh
To select another unprivileged destination:
AINFRA_INSTALL_DIR="$HOME/bin" sh /tmp/ainfra-install.sh
The installer supports these release targets:
| Operating system | Architecture | Release target |
|---|
| Linux | x86_64 | x86_64-unknown-linux-gnu |
| Linux | ARM64 | aarch64-unknown-linux-gnu |
| macOS | Intel | x86_64-apple-darwin |
| macOS | Apple Silicon | aarch64-apple-darwin |
After installation, the script runs both ainfra --version and
ainfra --help. If the destination is not already on PATH, it prints the
directory that must be added.
For development from a source checkout, use the pinned Rust toolchain:
cargo build
./target/debug/ainfra --version
1.2 - Quickstart
Validate ainfra and prepare a disposable Hetzner deployment.
This guide takes you from an installed binary to a reviewed disposable-
infrastructure plan. Applying the plan creates billable Hetzner resources, so
the final apply and destroy commands remain explicit.
Prerequisites
- A verified
ainfra release from the installation guide - OpenTofu
- Ansible
- A Hetzner Cloud project token for live operations
- An existing SSH public key
Install and validate
Check local readiness without changing infrastructure:
Initialize a disposable project
Create a separate project and initialize it:
mkdir ../my-infrastructure
cd ../my-infrastructure
ainfra init \
--name my-infrastructure \
--environment development
ainfra validate
ainfra doctor --environment development
Commit ainfra.yaml, ainfra.lock, and the environment input. Keep
.ainfra/ ignored; it contains operational run state. Edit
environments/development.yaml with the location, SSH public key, topology,
and network policy. Export the token; never place it in a project file:
export HCLOUD_TOKEN='...'
Plan and review
ainfra plan \
--environment development
Review the resource count, networking, public-address choices, and ownership
scope. The command returns a plan identifier. Apply requires that exact
identifier:
ainfra apply \
--environment development \
--approve PLAN_ID
Cost and teardown
Apply creates billable resources. Keep the input, state, ownership scope, and
destroy command available throughout the test. Do not end a disposable test
until Hetzner and the local state both confirm that no managed resources
remain.
ainfra outputs \
--environment development \
--run PLAN_ID \
--format json
ainfra configure \
--environment development \
--run PLAN_ID \
--known-hosts .ainfra/known_hosts
ainfra status --environment development
Verify SSH host-key fingerprints through the Hetzner console or another
trusted out-of-band channel before the first Ansible connection. Never treat
ssh-keyscan as a source of trust.
Tear down
Create and review a destroy plan:
ainfra plan \
--environment development \
--destroy
Then use the exact destroy-plan ID returned by the lifecycle:
ainfra down \
--environment development \
--approve-destroy PLAN_ID
ainfra status --environment development
Confirm zero project-owned servers, networks, firewalls, and SSH keys in
Hetzner, then verify that local OpenTofu state contains no resources.
Next steps
2 - Concepts
Architecture, security, and state boundaries.
These pages explain why ainfra separates project configuration, contracts,
infrastructure state, host configuration, and workload deployment.
2.1 - Architecture
Tool ownership, public contracts, and lifecycle boundaries.
Portfolio boundary
ainfra provisions and configures infrastructure targets. It hands a
non-secret output contract to downstream systems:
InfrastructureTemplate + TemplateInput
│
▼
validation and policy
│
▼
OpenTofu plan and state
│
▼
Ansible host configuration
│
▼
InfrastructureOutput
│
▼
workload deployment
OpenTofu owns infrastructure desired state. Ansible owns host configuration.
The ainfra wrapper validates contracts and visibly orchestrates those tools.
Every wrapper operation has a documented direct-tool equivalent.
The first reference implementation is Hetzner-specific and produces
Kubernetes-ready hosts. It does not install or claim to operate Kubernetes.
Provider abstraction and workload deployment are outside the initial scope.
Public contracts
Three strict JSON Schema draft 2020-12 contracts form the hand-off boundary:
InfrastructureTemplate declares engine locations, contract files,
capabilities, and mandatory security invariants.TemplateInput carries non-secret configuration and references to
credentials or ignored local configuration.InfrastructureOutput carries stable target identity, non-secret network
facts, inventory metadata, and references to credentials.
Unsupported versions, kinds, capabilities, and unknown fields fail before
mutation. Template metadata is not an executable DSL and cannot declare
arbitrary shell hooks.
Lifecycle
Planning creates an artifact that can be reviewed independently. Apply accepts
the exact plan identifier, not a general confirmation. Destroy accepts the
exact reviewed destroy-plan ID, not a generic yes/no flag. These bindings
prevent a
later command from silently acting on a different resource set.
Local state is permitted only for inputs explicitly marked disposable.
Long-lived environments require a capability-validated remote backend with
encryption, locking, version recovery, TLS, and access control.
2.2 - Security model
Threat assumptions, enforced invariants, and verification layers.
Assumptions
Cloud credentials, state, plans, private keys, generated inventories, and
provider logs may contain sensitive information. The local operator environment
is trusted to hold short-lived credentials; the repository and ordinary output
documents are not secret stores.
The model reduces accidental exposure and unsafe defaults. It does not turn an
untrusted workstation or compromised provider account into a trusted one.
Invariants
- Operators supply public SSH keys;
ainfra never creates private keys. - Root login and password SSH are prohibited.
- SSH host-key checking is mandatory.
- Management ingress is private by default.
- Public IPv4 allocation is opt-in.
- Private networks use narrow RFC1918 ranges and reject broad management
ranges.
- Non-disposable environments require encrypted, locked, recoverable remote
state with TLS and access control.
- Providers, collections, roles, images, and scanners are pinned.
- Standard outputs contain references to credentials, never their contents.
- Destructive operations name their exact reviewed destroy-plan ID and require
explicit approval.
Layered verification
| Layer | What it proves |
|---|
| JSON Schema | Document shape, version, enums, and unknown-field rejection |
| Policy checks | Cross-field security invariants |
| OpenTofu validation | Provider configuration and expression correctness |
| Checkov | Known infrastructure-policy findings |
| Gitleaks | Repository secret patterns |
| Plan assertions | The proposed resource graph matches safety expectations |
| Ansible checks | Syntax, lint, check mode, and idempotence |
| Disposable live test | Provider behavior and end-to-end teardown |
No single layer is treated as complete proof. Live Hetzner verification is the
final cost-bearing gate and requires explicit approval.
SSH trust
ssh-keyscan can collect a key but cannot authenticate it. Before the first
connection, compare the server fingerprint through a trusted Hetzner console or
another out-of-band channel and place the verified key in known_hosts.
Reporting vulnerabilities
Do not open public issues containing credentials, state, plans, inventories, or
exploitation details. Use the repository security contact or GitHub private
vulnerability reporting when available.
2.3 - Project configuration and locking
Committed desired state and generated template provenance.
An ainfra project separates committed intent from local operational state:
my-infrastructure/
├── ainfra.yaml
├── ainfra.lock
├── environments/
│ └── development.yaml
└── .ainfra/
ainfra.yaml names the project, selects one built-in template, and maps
environment names to TemplateInput files. Those files hold non-secret
provider, topology, network, access, and state intent. Credentials remain
external references such as HCLOUD_TOKEN.
ainfra.lock is generated by ainfra init and should normally be committed.
It pins the template source, name, manifest version, and SHA-256 digest of the
complete embedded template. Normal project validation never rewrites it.
.ainfra/ is ignored operational state. Reviewed plans, retained workspaces,
outputs, and other run artifacts belong there, not in committed
configuration.
Discovery and containment
Running ainfra validate without a target searches from the current directory
upward and selects the nearest ainfra.yaml. Project configuration, lockfiles,
and environment inputs must be regular files rather than symbolic links.
Environment paths must be relative and remain inside the canonical project
root.
Lifecycle commands select project mode explicitly with --environment ENV.
The reviewed plan uses ainfra.plan/v1alpha2 and binds the canonical project
root, ainfra.yaml, ainfra.lock, selected input, embedded template, backend,
and OpenTofu plan bytes. Any committed-input change requires a new plan.
The explicit TEMPLATE --input INPUT compatibility mode remains available for
existing development runs. It uses the legacy v1alpha1 record protocol.
Neither record version can authorize execution through the other mode.
Legacy explicit records may remain under the original repository’s ignored
.ainfra/ directory as audit and recovery evidence. They cannot be upgraded
in place, filled with project fields, or used by up or down. Inspect them
with ainfra legacy inspect --root PATH; normal project lifecycle requires a
new v1alpha2 plan bound to the exact project, lockfile, input, template, and
backend configuration.
2.4 - State and secrets
Backend requirements, secret references, and recovery.
State
Non-disposable environments must use a capability-validated remote backend
providing encryption at rest, locking, version recovery, TLS, and access
control. S3-compatible services qualify only after integration tests prove
those capabilities.
Local state is limited to inputs explicitly marked disposable and produces a
prominent warning. The wrapper does not provision, repair, or silently migrate
a backend.
Backend configuration stays outside version control. The repository ignores
local state, plans, .terraform/, and .ainfra/.
Secret references
Contracts accept references:
projectTokenRef:
type: environment
name: HCLOUD_TOKEN
They do not accept a token value. Credentials are resolved only when an
underlying tool needs them and are passed through the child-process
environment, never command arguments.
Raw state, plans, logs, inventories, and live-verification evidence remain in
ignored local storage. Only an explicitly sanitized report may be promoted
into documentation.
Recovery
Recovery procedures must identify:
- the backend version to restore;
- the target state lineage;
- the operator authorizing the action;
- the reviewed direct OpenTofu command;
- the verification that follows recovery.
Automatic state repair and migration are intentionally out of scope.
Python-prototype recovery
Treat an old Python checkout and its .ainfra/ directory as read-only
evidence. Start with:
ainfra legacy inspect --root /path/to/old/ainfra-checkout --format json
The report can validate legacy record structure and saved plan bytes, but it
cannot prove whether apply or destroy ran, which backend was active at that
time, or which resources currently exist. Its lifecycle state is therefore
always unknown.
Before any recovery action:
- preserve the original checkout,
.ainfra/, state, and backend metadata; - identify the exact backend, backend version, and state lineage through
operator-reviewed backend-native tooling;
- identify the operator authorizing recovery and record a reviewed direct
OpenTofu command;
- compare
tofu state list with provider-owned resources and ownership
labels; - initialize a new ainfra project only after its configuration references the
same verified backend and intended environment;
- create and review a fresh project-bound plan—never reuse a legacy plan ID;
- independently verify provider resources and state after apply or teardown.
For legacy local state, preserve the old checkout strictly as read-only
evidence and use an operator-approved OpenTofu state recovery procedure until
the environment is retired or its state has been deliberately moved. The old
Python CLI is not a supported recovery runtime. ainfra does not copy, rewrite,
import, or infer that state automatically.
2.5 - Template strategy
How ainfra turns versioned template source into reviewable infrastructure.
An ainfra template is a versioned, self-contained infrastructure
implementation behind a common contract. Templates share lifecycle and safety
rules, but keep provider-specific OpenTofu, host configuration, and operational
documentation together.
Why templates exist
The template boundary separates three concerns:
- The ainfra wrapper owns discovery, contract validation, reviewed-plan
binding, sanitized outputs, and ownership-scoped destruction.
- A template owns provider resources, host configuration, supported images,
capabilities, and provider-specific defaults.
- The operator owns the selected template, non-secret intent, credential
references, reviewed plan, and explicit lifecycle approvals.
This lets a template evolve without hiding OpenTofu or Ansible. Every engine
working directory remains directly usable and inspectable.
Source and discovery
All bundled templates are direct children of the repository’s templates/
directory:
templates/
└── hetzner-kubernetes-baseline/
├── ainfra-template.yaml
├── README.md
├── inputs/
├── tofu/
├── cloud-init/
└── ansible/
The directory name is the template identifier. ainfra accepts that
identifier, resolves exactly templates/<identifier>/, and requires an
ainfra-template.yaml whose metadata.name matches the directory. It does
not search arbitrary paths or download templates at runtime.
See the template catalog for the
templates currently included in the repository.
What “build” means
Templates are source, not compiled packages. Building a template means
assembling and validating a coherent directory:
manifest + input contract + OpenTofu + Ansible + examples + tests
→ contract and path validation
→ static policy and engine validation
→ reviewed OpenTofu plan bound to template content
→ apply and host configuration
→ sanitized InfrastructureOutput
The manifest declares the template version, compatible wrapper versions,
engine working directories, input and output schemas, output location,
capabilities, and mandatory security properties. During planning, ainfra
hashes the complete template directory. Apply refuses to continue if the
template, input, environment, version, or reviewed plan bytes have changed.
Shared schemas live in schemas/. Template-specific implementation and
examples stay under the template directory. This prevents a template from
silently weakening the common consumer contract.
Evolution rules
- Patch versions correct behavior without changing the declared contract.
- Minor versions may add compatible options or capabilities.
- Breaking contract changes require the repository’s migration process; the
v1alpha1 API cannot be silently replaced. - Provider, module, collection, role, image, and scanner dependencies remain
pinned.
- A new capability must be supported by the wrapper before a manifest can
declare it.
- Live-support claims require a disposable apply, idempotence check, complete
teardown, and redacted evidence.
3 - How-to guides
Complete procedures for common ainfra operator tasks.
How-to guides provide goal-oriented, copyable procedures for operators who
already understand the basic ainfra concepts.
Start with Create and tear down a Hetzner
environment for
the complete baseline-template lifecycle.
3.1 - Create and tear down a Hetzner environment
Configure, plan, apply, inspect, and destroy the baseline template.
This tutorial creates a disposable Hetzner Cloud environment with the
hetzner-kubernetes-baseline template and the ainfra CLI. It provisions one
Debian 13 control-plane-capable host, a private network, firewall rules, and
the operator SSH key registration. It then destroys every managed resource.
Cost and responsibility
The apply step creates billable Hetzner resources. Check current pricing and
quotas in your Hetzner project before continuing. Keep the input, local state,
plan records, token, and destroy procedure available until independent checks
confirm that no managed resources remain.
What this procedure does
Hetzner project + token + SSH public key
→ non-secret TemplateInput
→ validate and check readiness
→ create and review an OpenTofu plan
→ approve that exact plan with ainfra
→ inspect the resulting environment
→ create and review a destroy plan
→ approve that exact destroy plan
→ verify zero remaining resources
ainfra apply runs the reviewed OpenTofu plan. It does not automatically run
the template’s Ansible playbook or install Kubernetes. Host configuration
beyond cloud-init is a separate, explicit operation.
1. Prepare the workstation
Install the prerequisites:
- a verified
ainfra release; - OpenTofu
1.10.0 or newer; - Ansible Core
2.16.0 or newer; jq for reading machine output;- an Ed25519 SSH key pair.
Install ainfra using the installation guide, then verify the binary:
2. Create a Hetzner project token
Open the intended project in
Hetzner Console. Under Security → API
tokens, generate a project-specific Read & Write token. Hetzner displays
the full token only once, so transfer it directly to an approved secret
manager.
The token is bound to the selected Hetzner project. A read-only token cannot
create or destroy the resources in this procedure. See Hetzner’s official
API-token instructions.
Load the token into the current shell without writing it into the repository
or input document:
read -rsp "Hetzner API token: " HCLOUD_TOKEN
printf '\n'
export HCLOUD_TOKEN
The example input refers to the environment variable by name:
projectTokenRef:
type: environment
name: HCLOUD_TOKEN
3. Prepare an SSH public key
Use an existing Ed25519 key or create one:
ssh-keygen -t ed25519 -f ~/.ssh/ainfra-hetzner \
-C "ainfra disposable Hetzner environment"
Display the public half:
cat ~/.ssh/ainfra-hetzner.pub
Only the single-line .pub value belongs in the input. Never copy the private
key into the repository, YAML document, state configuration, or plan record.
Create and enter a standalone project:
mkdir ainfra-hetzner-tutorial
cd ainfra-hetzner-tutorial
ainfra init \
--name ainfra-hetzner-tutorial \
--environment tutorial
Edit the generated environments/tutorial.yaml:
apiVersion: ainfra.projectious.work/v1alpha1
kind: TemplateInput
metadata:
template: hetzner-kubernetes-baseline
environment: tutorial
disposable: true
spec:
provider:
location: fsn1
projectTokenRef:
type: environment
name: HCLOUD_TOKEN
topology:
controlPlaneCount: 1
workerCount: 0
image: debian-13
serverType: cx23
network:
privateCidr: 10.42.0.0/16
publicIPv4: true
publicIPv6: false
managementIngressCidrs:
- 203.0.113.24/32
workloadIngressCidrs: []
access:
adminSshPublicKeys:
- ssh-ed25519 REPLACE_WITH_YOUR_PUBLIC_KEY operator@example.com
state:
mode: local-disposable
Replace both placeholders. 203.0.113.24/32 must become the trusted public
IPv4 egress address of the operator workstation or VPN. Do not use
0.0.0.0/0; policy requires IPv4 management ranges of /24 or narrower, and
this tutorial deliberately uses a single /32.
| Field | Configure it as follows |
|---|
metadata.environment | A unique lowercase environment name. It becomes part of resource names and ownership labels. |
metadata.disposable | Keep true when using local-disposable state. |
provider.location | Choose fsn1, nbg1, or hel1, the European locations supported by this contract. |
projectTokenRef | Keep the HCLOUD_TOKEN environment reference; never paste the token value. |
controlPlaneCount | Exactly 1 in the current contract. |
workerCount | 0 for the smallest tutorial, or the required non-negative count. |
image | debian-13, currently the only validated image. |
serverType | cx23 in the example; confirm availability and price in the selected location. |
privateCidr | A private IPv4 range that does not overlap networks used to reach the environment. |
publicIPv4 | true for direct tutorial access; this can incur a separate charge. |
publicIPv6 | Keep false unless IPv6 access is explicitly required and tested. |
managementIngressCidrs | Only trusted operator or VPN egress CIDRs. These permit TCP/22 when a public address is enabled. |
workloadIngressCidrs | Keep empty; the baseline does not install a workload. |
adminSshPublicKeys | One or more Ed25519 public keys, never private keys. |
state.mode | local-disposable only for an environment that will be torn down in this session. |
For a private-only environment, set both public-address values to false and
leave both ingress lists empty. You must then already have a trusted route into
the Hetzner private network; ainfra does not create a VPN or bastion.
Validate the project and its declared environment:
ainfra validate
ainfra doctor --environment tutorial
These commands do not create infrastructure. Resolve every error before
planning.
Keep the project configuration, lockfile, and environment input unchanged
between plan and apply. ainfra binds the reviewed plan to their exact bytes,
the template version and digest, backend identity, and plan bytes.
6. Create the apply plan
Create a machine-readable plan record:
ainfra plan --environment tutorial \
--format json | tee .ainfra/apply-plan.json
Extract its exact identifier and plan path:
APPLY_PLAN_ID="$(jq -r '.id' .ainfra/apply-plan.json)"
APPLY_PLAN_PATH="$(jq -r '.plan_path' .ainfra/apply-plan.json)"
APPLY_WORKSPACE="$(dirname "$APPLY_PLAN_PATH")"
printf 'Apply plan ID: %s\n' "$APPLY_PLAN_ID"
7. Review the plan
Render the exact saved OpenTofu plan:
tofu show "$APPLY_PLAN_PATH"
Before approval, verify:
- exactly one server and zero workers for this tutorial;
- the expected
cx23 server type and debian-13 image; - the selected location;
- one private network and subnet using the chosen CIDR;
- public IPv4 enabled and public IPv6 disabled;
- TCP/22 allowed only from the trusted management
/32; - no workload ingress;
- ownership labels containing
managed-by=ainfra,
template=hetzner-kubernetes-baseline, and environment=tutorial; - registrations for only the intended SSH public keys;
- the resource count and current estimated Hetzner cost are acceptable.
Do not edit the input or template after planning. If anything is wrong, discard
the plan and create a new one after correcting the input.
8. Apply the reviewed plan
Apply only the exact plan ID printed above:
ainfra apply --environment tutorial \
--approve "$APPLY_PLAN_ID"
Do not interrupt the command unless continuing would be more dangerous. If it
fails, inspect both the Hetzner project and local state before retrying or
destroying.
9. Confirm the environment
Inspect the local OpenTofu state:
tofu -chdir="$APPLY_WORKSPACE" state list
tofu -chdir="$APPLY_WORKSPACE" output -json inventory_nodes
tofu -chdir="$APPLY_WORKSPACE" output -json ownership
In Hetzner Console, confirm the expected server, network, firewall, and SSH-key
registrations. Their names begin with ainfra-tutorial-, and their labels
identify the template and environment.
Before the first SSH connection, obtain the server host-key fingerprint
through the Hetzner console or another trusted out-of-band channel. Add the
verified key to known_hosts; do not use ssh-keyscan as the source of trust.
Cloud-init creates the ainfra user and installs its authorized public key.
Ansible is separate
The infrastructure is now up, but the CLI has not run Ansible. The baseline’s
Ansible playbook is intentionally explicit. Its generated inventory uses
private node addresses, so run it only from a trusted host with private-network
reachability and after host-key verification. See the
Hetzner baseline reference.
10. Create and review the destroy plan
Do not reuse the apply plan. Create a dedicated destroy plan from the unchanged
template and input:
ainfra plan --environment tutorial \
--destroy \
--format json | tee .ainfra/destroy-plan.json
Extract the destroy approval ID and plan path:
DESTROY_PLAN_ID="$(jq -r '.id' .ainfra/destroy-plan.json)"
DESTROY_PLAN_PATH="$(jq -r '.plan_path' .ainfra/destroy-plan.json)"
DESTROY_WORKSPACE="$(dirname "$DESTROY_PLAN_PATH")"
printf 'Destroy plan ID: %s\n' "$DESTROY_PLAN_ID"
Review it:
tofu show "$DESTROY_PLAN_PATH"
Confirm that it removes every resource owned by the tutorial environment and
does not affect unrelated Hetzner resources.
11. Destroy the environment
The exact destroy-plan ID is the scope-bound approval token expected by
--approve-destroy:
ainfra destroy --environment tutorial \
--approve-destroy "$DESTROY_PLAN_ID"
12. Verify complete teardown
The state list must be empty:
tofu -chdir="$DESTROY_WORKSPACE" state list
Independently inspect the Hetzner project and confirm that no tutorial-owned
servers, networks, firewalls, primary IPs, or SSH-key registrations remain.
Check for the environment name and the ownership labels rather than relying
only on the command exit status.
Only after both checks are clean:
unset HCLOUD_TOKEN APPLY_PLAN_ID APPLY_PLAN_PATH
unset DESTROY_PLAN_ID DESTROY_PLAN_PATH
Retain redacted evidence if required, then remove the disposable local state
and plan records according to your project’s retention policy. Never delete
state or plan records before teardown has been independently confirmed.
Troubleshooting guarded operations
If apply or destroy reports that the reviewed plan no longer matches, do not
bypass the guard. Common causes are:
- the input path or content changed;
- the template changed;
- the saved plan was removed or modified;
- the plan ID belongs to the opposite operation;
- a different environment or template was selected.
Revalidate, create a new plan, review it, and approve its new exact ID.
For general commands, see Lifecycle operations. For contract fields, see Contracts.
4 - Guides
Operate the lifecycle, tooling, and template-authoring workflow.
Use these guides after the quickstart when you need the complete operational
sequence or want to extend the template catalog.
4.1 - Lifecycle operations
Validate, plan, apply, inspect, and destroy infrastructure.
Validate
ainfra validate path/to/document.yaml
ainfra validate path/to/document.json --format json
Validation reads a document but does not resolve secret references or run an
infrastructure engine.
Check readiness
ainfra doctor
ainfra doctor --format json
ainfra doctor --input .ainfra/hetzner.input.yaml
ainfra doctor --environment development
The input-aware form also checks backend readiness for the intended
environment.
Plan
ainfra plan TEMPLATE --input INPUT
For an initialized project, use the declared environment:
ainfra plan --environment development
Treat the plan as sensitive. Review resource ownership, addresses, firewall
rules, image selection, and estimated cost. Apply only the returned plan ID:
ainfra apply TEMPLATE --input INPUT --approve PLAN_ID
The corresponding project-mode apply is:
ainfra apply --environment development --approve PLAN_ID
For the complete project workflow, retain the same review boundary and pass
that exact ID to up:
ainfra plan --environment development
# Review the saved plan, then:
ainfra up --environment development --approve PLAN_ID \
--known-hosts ./known_hosts
up never plans or chooses an approval implicitly. It composes the exact
apply, validated output collection, configuration, and a separate Ansible
check-mode verification stage. If a stage was interrupted after its started
event or ended in failure, up stops and routes the operator to manual
recovery.
The Rust implementation embeds the built-in template and materializes a new
workspace beneath .ainfra/runs/<PLAN_ID>/workspace/; it does not execute
OpenTofu in a source checkout. Initialization uses the committed lock file in
read-only mode. The generated variables, plan bytes, and versioned plan record
remain together in the isolated run directory.
Before apply, ainfra revalidates the input and verifies the operation,
template identity and version, environment, canonical input path and bytes,
retained workspace contents, remote backend configuration path and bytes, plan
location, and plan bytes. Project mode additionally binds the canonical project
root plus the exact ainfra.yaml and ainfra.lock bytes. Any mismatch fails
before OpenTofu initialization or apply begins.
Each completed plan has an immutable run.json and append-only events beneath
.ainfra/runs/<PLAN_ID>/events/. Mutating and configuration phases record a
started event before execution and then a succeeded or sanitized failed
event. A missing terminal event means the process may have been interrupted;
do not infer success or retry automatically.
Inspect local state without contacting a provider:
ainfra status --environment development
ainfra status --environment development --format json
Status validates event ordering and bindings, ignores untrusted run entries,
and emits deterministic next steps. A partial, stale, corrupt, or legacy state
routes to manual recovery instead of an apply or destroy command.
Teardown uses the same two-step review:
ainfra plan --environment development --destroy
# Review the dedicated destroy plan, then:
ainfra down --environment development --approve-destroy DESTROY_PLAN_ID
An apply-plan ID can never authorize down, and down never runs a direct
unreviewed destroy. A successful destroy process first records
destroy-applied; only a subsequent empty tofu state list for the reviewed
backend records the environment as destroyed.
Read standardized outputs
ainfra outputs TEMPLATE --run PLAN_ID --format yaml
ainfra outputs TEMPLATE --run PLAN_ID --format json
ainfra outputs --environment development --run PLAN_ID
The standardized output is designed for downstream automation. It must not be
confused with raw provider outputs or state. Output collection requires a
successful apply marker for the same exact run and writes
.ainfra/runs/PLAN_ID/output.json.
ainfra configure TEMPLATE \
--run PLAN_ID \
--known-hosts .ainfra/known_hosts
ainfra configure --environment development \
--run PLAN_ID \
--known-hosts .ainfra/known_hosts
The command generates a run-local inventory using private management
addresses, then runs the retained Ansible playbook. Verify SSH fingerprints
through an independent channel and populate the protected known_hosts file
before running it. Use --check for an explicit Ansible check-mode pass.
Destroy
Create the destroy plan first:
ainfra plan TEMPLATE --input INPUT --destroy
ainfra plan --environment development --destroy
Then approve the returned ownership scope:
ainfra destroy TEMPLATE \
--input INPUT \
--approve-destroy PLAN_ID
ainfra destroy --environment development \
--approve-destroy PLAN_ID
Destroy applies the exact reviewed destroy plan through tofu apply; it never
uses an unreviewed direct tofu destroy operation. An apply plan cannot
authorize destroy, and a destroy plan cannot authorize apply.
After a disposable test, confirm zero owned resources in both Hetzner and
OpenTofu state. A successful command without this independent check is not
complete teardown evidence.
ainfra remains a thin wrapper. If diagnosis requires direct OpenTofu or
Ansible commands, use the template working directories and preserve the same
input, state, and ownership scope. Never bypass the reviewed-plan or teardown
controls merely for convenience.
4.2 - Local validation
Install and run the repository’s local quality and security gates.
The repository intentionally runs its gates locally. It does not contain
GitHub Actions workflow files.
The supported bootstrap installs pinned tools into the ignored
.ainfra/tools/ directory:
scripts/bootstrap-security-tools
It installs:
- Checkov
3.2.529 in an isolated uv tool environment; - Gitleaks
8.30.1, verified against its release checksum.
Checkov is isolated because its dependency constraints conflict with the
project environment. Runtime version checks fail closed.
Run all gates
scripts/validate-all
scripts/test-all
The validation suite covers formatting, typing, contracts, OpenTofu,
Ansible, repository policy, Rust Clippy and RustSec checks, Checkov, and
Gitleaks. Rust 1.96.1 is pinned in rust-toolchain.toml; the aibox workspace
installs Rust, Clippy, Rustfmt, and cargo-audit. Missing required tooling is a
failure, not a skipped check.
Build the documentation
docs/scripts/build-docs.sh
docs/scripts/serve-docs.sh
The local server uses
http://localhost:1313/ainfra/ so relative links behave like the
GitHub Pages deployment.
4.3 - Authoring templates
Add a contract-compliant infrastructure template.
Read the template strategy first. This guide covers the concrete
authoring workflow.
Choose adaptation or a new template
Adapt an existing template when the provider, resource topology, lifecycle,
and security model remain the same. Typical adaptations include:
- changing sizes, counts, locations, or opt-in networking through inputs;
- adding another explicitly supported operating-system image;
- extending an existing Ansible role;
- adding a compatible output or capability already understood by ainfra.
Create a new template when the provider changes, the topology has different
ownership or teardown behavior, the engine layout changes materially, or the
existing template’s security promises would become misleading.
Do not fork a template merely to change an environment value. Copy its example
input and keep the template source unchanged instead.
Find and inspect templates
Bundled templates are direct children of templates/. The current catalog is
listed in Template reference.
Inspect the manifest and template README before changing anything:
find templates -mindepth 2 -maxdepth 2 -name ainfra-template.yaml -print
sed -n '1,240p' \
templates/hetzner-kubernetes-baseline/ainfra-template.yaml
sed -n '1,240p' templates/hetzner-kubernetes-baseline/README.md
Adapt the existing template
For an environment-specific adaptation:
- Copy
templates/hetzner-kubernetes-baseline/inputs/example.input.yaml
outside the template’s tracked examples. - Keep
metadata.template: hetzner-kubernetes-baseline. - Change only documented input fields.
- Keep credentials as references or environment variables, never values.
- Validate the template and input together.
- Review the generated plan and retain immediate teardown readiness.
cp templates/hetzner-kubernetes-baseline/inputs/example.input.yaml \
/tmp/my-environment.input.yaml
ainfra validate hetzner-kubernetes-baseline \
--input /tmp/my-environment.input.yaml
If the implementation itself must change, edit the OpenTofu, cloud-init, or
Ansible source in the existing directory, increment metadata.version, update
the supported-value documentation, and add tests for the changed behavior.
Create a new template
Start from the nearest existing implementation only when its security defaults
are appropriate:
cp -R templates/hetzner-kubernetes-baseline templates/my-template
Then complete every step:
- Rename the directory using lowercase letters, numbers, and hyphens.
- Set the same identifier in
ainfra-template.yaml at metadata.name. - Reset
metadata.version for the new template and describe its purpose. - Declare the supported ainfra wrapper range.
- Point both engine working directories to paths inside the template.
- Reference only shared schemas that are direct children of
schemas/. - Replace example inputs, embedded template identifiers, output metadata,
provider resources, and README content.
- Declare only capabilities supported by
src/template.rs. - Pin OpenTofu providers and Ansible collections and roles.
- Add positive and negative contract, policy, engine, and lifecycle tests.
Discovery does not traverse arbitrary paths or accept absolute template
locations. A directory outside templates/, a nested catalog, or a manifest
whose name differs from its directory is rejected.
Required directory contract
The exact implementation directories can vary when declared in the manifest,
but a complete template has this shape:
templates/my-template/
├── ainfra-template.yaml
├── README.md
├── inputs/
│ └── example.input.yaml
├── tofu/
│ ├── .terraform.lock.hcl
│ ├── versions.tf
│ ├── providers.tf
│ ├── variables.tf
│ └── outputs.tf
└── ansible/
├── requirements.yml
└── site.yml
Cloud-init, roles, modules, and other source belong under the same template
root. Runtime state and generated output belong under ignored .ainfra/ or
OpenTofu state locations, never in Git.
Validate the result
Run narrow checks while authoring, then the complete repository gates:
ainfra validate my-template \
--input templates/my-template/inputs/example.input.yaml
tofu -chdir=templates/my-template/tofu fmt -check -recursive
tofu -chdir=templates/my-template/tofu init -backend=false
tofu -chdir=templates/my-template/tofu validate
scripts/validate-all
scripts/test-all
Also document the direct OpenTofu and Ansible commands so operators can inspect
and troubleshoot without the wrapper. Before claiming provider support, run a
cost-approved disposable lifecycle: plan, apply, host verification, Ansible
check, apply, idempotence check, destroy, and provider/state confirmation.
Author checklist
- The manifest validates against
template-manifest.v1alpha1.json. - All manifest paths remain inside the allowed template or schema roots.
- Example inputs are useful and contain no secrets.
- Supported values and exclusions are explicit.
- Standard outputs validate as
InfrastructureOutput/v1alpha1 and contain no
secret-shaped data. - New behavior has positive and negative tests.
- Dependencies and images are pinned.
- Direct engine commands and teardown are documented.
- The full local validation suite is green.
Image choices
Every user-selectable image must list and explain all supported values. The
initial template supports:
# Supported images:
# - debian-13: official Hetzner Debian 13 image; the default and only
# initially validated operating system.
image: debian-13
Adding an image is not merely an enum change. It requires hardening,
architecture, networking, update, idempotence, and disposable-lifecycle
verification.
Keep the contract narrow
Template metadata is declarative and cannot add arbitrary shell hooks. If a
new capability is needed, define and test its contract semantics before
implementing provider behavior.
5 - Reference
Command, contract, template, and acceptance references.
Use this section when you already know the workflow and need exact command or
schema details.
5.1 - CLI
The complete ainfra command surface.
ainfra init [--name NAME] [--template TEMPLATE]
[--environment ENVIRONMENT] [--format text|json]
ainfra validate [<path-or-template>] [--input INPUT]
[--format text|json]
ainfra doctor [--input INPUT | --environment ENV] [--format text|json]
ainfra plan [<template> --input INPUT | --environment ENV]
[--destroy] [--format text|json]
ainfra apply [<template> --input INPUT | --environment ENV]
--approve PLAN_ID
ainfra up --environment ENV --approve PLAN_ID --known-hosts FILE
[--format json|yaml]
ainfra destroy [<template> --input INPUT | --environment ENV]
--approve-destroy PLAN_ID
ainfra down --environment ENV --approve-destroy PLAN_ID
ainfra outputs [<template> | --environment ENV] --run PLAN_ID
[--format json|yaml]
ainfra configure [<template> | --environment ENV] --run PLAN_ID
--known-hosts FILE [--check]
ainfra status --environment ENV [--format text|json]
ainfra legacy inspect [--root PYTHON_REPOSITORY] [--format text|json]
ainfra inventory --output OUTPUT --destination DESTINATION
init
Initializes the current directory without contacting a provider or invoking
OpenTofu or Ansible. It creates ainfra.yaml, ainfra.lock, and one example
under environments/, then adds .ainfra/ to .gitignore.
Initialization preflights every primary file and refuses to overwrite any of
them. There is intentionally no force flag. The lock pins the selected
built-in template’s name, version, source, and content digest.
validate
Validates an InfrastructureTemplate, TemplateInput, or
InfrastructureOutput document. It does not resolve credentials or mutate
infrastructure. With no target, it discovers the nearest ancestor
ainfra.yaml and validates the project, lockfile, environment references,
template digest, contracts, and policy.
doctor
Reports local dependency and configuration readiness without mutation.
Providing --input adds checks that depend on the target environment’s
backend mode and capabilities. --environment selects project mode and checks
the configured input plus compatible OpenTofu and Ansible versions. The two
selectors are mutually exclusive.
plan
Validates the template and input, invokes OpenTofu planning, applies policy
checks, and emits a reviewable plan ID. The built-in template is materialized
into an isolated per-plan workspace. --destroy creates the corresponding
destruction review without bypassing the exact-plan gate.
Project mode uses --environment ENV and discovers the nearest ancestor
ainfra.yaml. Explicit compatibility mode remains
TEMPLATE --input INPUT; mixing selectors fails before any subprocess.
apply
Applies only the plan identified by --approve, from its retained isolated
workspace. A stale, absent, changed, or mismatched identifier fails closed
before an infrastructure process starts.
Project plans use the ainfra.plan/v1alpha2 protocol and bind the canonical
project root plus the exact bytes of ainfra.yaml and ainfra.lock. Legacy
explicit records remain v1alpha1; records cannot cross-authorize between
the two modes.
destroy
Destroys only through the exact destroy plan identified by
--approve-destroy. The implementation applies that reviewed plan rather than
running an unreviewed direct destroy command.
up
Completes a previously reviewed project apply plan. It does not create or
select a plan. After rechecking local readiness and the exact approval ID, it
composes apply, standardized output collection, Ansible configuration, and
Ansible check-mode verification. Successfully completed stages are resumed
without repeating their mutations; interrupted or failed stages require
manual recovery.
The required --known-hosts file remains independent host-identity evidence.
The resulting validated infrastructure output is emitted as JSON or YAML.
down
Applies only a previously reviewed project destroy plan. It never creates a
destroy plan, reuses an apply approval, selects the latest plan implicitly, or
invokes an unreviewed direct destroy command. After applying the exact plan,
it runs a separate tofu state list verification and records destroyed only
when no resources remain in the reviewed backend state.
outputs
Collects OpenTofu output for one exact successfully applied run, rejects
sensitive or ownership-inconsistent values, and emits the validated,
sanitized InfrastructureOutput. It never substitutes raw state or raw engine
output for that contract.
Builds a private-address inventory from one run’s validated output and invokes
the retained Ansible playbook. --known-hosts must name an independently
verified, non-group/world-writable host-key file. After a successful
configuration, --check records a distinct check-mode verification stage; it
never records configuration as applied.
inventory
Transforms a validated standardized output into an Ansible inventory at an
explicit destination.
legacy inspect
Inspects Python-prototype .ainfra/runs/ evidence without invoking OpenTofu,
Ansible, credential providers, or provider APIs and without writing files. It
reports sanitized record identities, exact plan-byte integrity, and corrupt
entry counts.
Legacy lifecycle state is always unknown. The command never imports a plan,
synthesizes project bindings or lifecycle events, or recommends applying or
destroying with an old ID. Machine output uses
ainfra.legacy-inspection/v1alpha1 and always requires a new project-bound
plan before normal project lifecycle can begin.
status
Reads only validated project files and durable local run evidence. It never
invokes OpenTofu, Ansible, credential providers, DNS, or provider APIs.
The report classifies the latest project-bound run as planned, applied,
output-collected, configured, verified, destroy-planned, destroyed,
destroy-applied, partial, stale, corrupt, legacy, or none. Started
operations without a matching success or failure event are partial; they are
never assumed safe to retry. Machine output uses ainfra.status/v1alpha1 and
includes sanitized checks and the next safe command or manual-recovery route.
5.2 - Contracts
Versioned schemas at the boundary between templates and consumers.
All schemas use JSON Schema draft 2020-12 and reject unknown fields.
Compatibility
The apiVersion is locked through the v1 series. An incompatible v2 requires
a full migration rather than silent coercion.
Validation behavior
- Unsupported
apiVersion and kind values fail. - Unknown properties fail.
- Secret-shaped standard outputs fail.
- Undeclared capabilities fail.
- Template paths cannot escape the template directory.
- Secret references describe a source but do not contain the secret.
Run:
Positive and negative fixtures live under tests/fixtures/contracts/.
Reviewed-plan records
A reviewed-plan record binds the intended operation to the selected template
and version, environment identity, canonical input path and bytes, template
tree, remote backend configuration, and generated OpenTofu plan bytes. Apply
and destroy must reject any changed binding before starting an infrastructure
process.
Explicit compatibility mode writes ainfra.plan/v1alpha1 and reads normal
records created by the Python implementation. Project mode writes
ainfra.plan/v1alpha2, adding the canonical project root and exact project
configuration and lockfile paths and digests. The two modes cannot
cross-authorize.
Approval identifiers are exactly 20 lowercase hexadecimal characters. Plan
files must resolve beneath their corresponding
.ainfra/runs/<PLAN_ID>/ directory.
Run records and events
run.json repeats only non-secret immutable plan identity. Lifecycle evidence
is append-only and ordered by a contiguous sequence number. Configuration,
check-mode convergence verification, and post-destroy zero-resource
verification are distinct stages; timestamps are informational and never
override event order. Failed events contain only a stable AINFRA-E* code and
a sanitized recovery category.
The older ainfra.run/v1alpha1 apply/destroy marker remains legacy success
evidence. It is not treated as a durable event history and is never upgraded
silently.
5.3 - Templates
Bundled infrastructure templates and where to find them.
ainfra embeds its built-in templates in the release binary. Their reviewed
source remains under templates/; each directory contains an
ainfra-template.yaml manifest and a template-specific README.
| Template | Source | Purpose | Status |
|---|
hetzner-kubernetes-baseline | templates/hetzner-kubernetes-baseline | Private-networked, hardened Debian 13 hosts prepared for a later Kubernetes installation | Initial validated template |
The binary does not currently use an external registry or download templates
at runtime. Clone the repository only when you want to inspect or adapt source.
Inspect locally
find templates -mindepth 2 -maxdepth 2 -name ainfra-template.yaml -print
ainfra validate hetzner-kubernetes-baseline \
--input templates/hetzner-kubernetes-baseline/inputs/example.input.yaml
The identifier supplied to the CLI is the directory name, not a filesystem
path:
ainfra plan hetzner-kubernetes-baseline \
--input /path/to/environment.input.yaml
For selection and extension guidance, see the
template strategy and
template authoring guide.
5.4 - Hetzner Kubernetes-ready baseline
The first pinned infrastructure template.
The baseline provisions one control-plane-capable Debian 13 host and optional
workers on a narrow private network. It does not install Kubernetes.
OpenTofu creates:
- a project-owned private network and subnet;
- a narrowly scoped firewall;
- registrations for operator-supplied SSH public keys;
- Debian 13 servers;
- public IPv4 or IPv6 attachments only when explicitly enabled.
Ansible configures:
- secure SSH daemon policy;
- nftables host firewall policy;
- unattended security updates;
- persistent journal and audit configuration;
- Kubernetes-ready operating-system prerequisites.
Networking
Public IPv4 and IPv6 are independent and disabled in the standard input.
Disabling IPv4 means no billable public IPv4 resource or attachment may exist.
The generated inventory prefers private management addresses.
Image
The complete supported image set contains one value:
debian-13 — the official Hetzner Debian 13 image, updated within its major
release and validated by this template.
Host verification
Before the first Ansible connection, verify each SSH host-key fingerprint
through a trusted console or another out-of-band channel. Add that verified key
to known_hosts.
The approved disposable live sequence performs:
- cloud-init schema validation;
- OpenTofu plan and apply;
- deterministic SSH host identity verification;
- Ansible check mode;
- Ansible apply;
- a second apply with zero changes;
- destroy;
- provider and state checks confirming zero resources.
Source layout
templates/hetzner-kubernetes-baseline/
├── ainfra-template.yaml
├── inputs/
├── tofu/
├── cloud-init/
└── ansible/
5.5 - Acceptance matrix
Evidence expected for the initial secure template release.
| Requirement | Evidence |
|---|
| Versioned strict contracts | Schema and positive/negative fixture tests |
| Visible wrapper commands | CLI, runner, lifecycle, and adapter tests |
| No template hardcoding | Contract-driven discovery and fixtures |
| Pinned Hetzner template | Provider lock, image enum, validation |
| Secure SSH and keys | Schema, policy, plan, and host tests |
| Safe state | Backend capability validation |
| Secret-free output | Strict schema, redaction, Gitleaks |
| Local validation gates | scripts/validate-all, scripts/test-all |
| Standalone Rust product | No Python package; installed-shell no-tool test |
| Disposable lifecycle | Approved live apply, idempotence, and teardown |
| Clear portfolio boundaries | README and architecture review |
| No unsafe donor behavior | Negative policy and plan tests |
| No GitHub workflows | Repository-policy test |
Every release criterion maps to an automated local check or documented manual
verification with sanitized evidence. A claim without one of those forms of
evidence remains unverified.
6 - Contributing
Develop, test, and document changes locally.
Issues and pull requests are welcome. Keep changes narrow, preserve the
security boundaries, and include evidence proportional to the risk.
Development setup
The aibox workspace installs the pinned Rust toolchain and cargo-audit.
The production implementation is entirely Rust. Python and uv are
development-only dependencies for repository policy, OpenTofu, and Ansible
tests; they are not part of the installed product.
uv sync --all-groups
scripts/bootstrap-security-tools
scripts/validate-all
scripts/test-all
Python, Rust, and Markdown lines are hard-wrapped at 80 columns. Use
Conventional Commits and never bypass hooks.
Branches
main contains the latest published stable release. Normal implementation
work integrates through v0.x-dev; feature branches start from and merge back
to that branch.
See the branching strategy for the maintenance, development,
prerelease, and stable-release promotion lanes.
Documentation
Build the site before opening a documentation change:
docs/scripts/build-docs.sh
Write task-oriented procedures as guides, stable facts as reference, and
design rationale in concepts. Update the README only as the concise front door;
the documentation site remains the detailed source.
Infrastructure changes
Infrastructure changes should include:
- strict contract or policy coverage;
- positive and negative tests;
- exact dependency pins;
- direct-tool equivalents;
- redacted evidence;
- a teardown plan before any live apply.
Live cost-bearing tests require separate explicit approval.
6.1 - Branching strategy
Promote v0 changes through isolated development and release lanes.
ainfra uses separate long-lived lanes for stable maintenance and v0
development.
Long-lived branches
main represents the latest published stable release. Stable release tags
are merged here after publication. It is the repository’s default branch.v0.x-maintenance carries supported fixes for published v0 releases.
Features are not backported unless explicitly approved.v0.x-dev is the normal integration branch for v0 implementation. Feature
branches and pull requests target this branch.v0.x-pre-release receives selected promotions from v0.x-dev for alpha,
beta, and release-candidate validation. Prerelease tags are created only
here.v0.x-release is promoted from the accepted prerelease state for general
availability. Stable v0 tags are created here and then merged into main.
feature branch
→ v0.x-dev
→ v0.x-pre-release
→ v0.x-release
→ main
Maintenance fixes begin on v0.x-maintenance. Promote a maintenance release
through the same validation expectations, and merge any still-relevant fix
forward into v0.x-dev.
Rules
- Never tag releases from
v0.x-dev. - Promote tested commits forward through pull requests; do not develop
directly on release-integration branches.
- Keep supported maintenance work isolated from new development.
- Use squash-merged pull requests with green local validation.
- Build and verify release artifacts locally before tagging.
- Treat published tags as immutable.
- Keep deployment-only branches, such as
gh-pages, outside the source
promotion flow.
Release verification
Run the complete local gate before entering the release lane:
./scripts/maintain.sh test
The container-side release builds and verifies both Linux targets before
publishing them:
AINFRA_RELEASE_CONFIRM=v0.1.0 \
./scripts/maintain.sh release 0.1.0
On macOS, the host phase builds both Darwin targets, verifies all four local
archives and checksum sidecars, uploads the Darwin assets, and then confirms
that the GitHub release contains every expected archive, checksum, and the
installer:
AINFRA_RELEASE_CONFIRM=v0.1.0 \
./scripts/maintain.sh release-host 0.1.0
To recheck a collected four-target artifact set without publishing anything:
./scripts/maintain.sh audit-release 0.1.0
Versioned documentation
The documentation root always represents main, the latest published stable
state. The Releases menu links to immutable documentation snapshots for
published versions.
Publish the current stable documentation:
docs/scripts/deploy-docs.sh
When publishing a release, first add its version and URL to params.versions
in docs/hugo.yaml. Build the accepted release commit or tag and publish its
snapshot under the matching path:
DOCS_VERSION=v0.1 docs/scripts/deploy-docs.sh
That command preserves the root site and replaces only /v0.1/. Versioned
pages identify themselves as archived snapshots and link readers back to the
latest documentation. Once published, a versioned snapshot should be treated
as immutable except for an explicitly approved documentation correction.