This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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:

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

If you want to…Start here
Validate and provision a disposable targetQuickstart
Follow the complete Hetzner lifecycleHetzner environment how-to
Understand ownership and tool boundariesArchitecture
Review threat assumptions and invariantsSecurity model
Operate plan, apply, output, and destroyLifecycle operations
Find an included infrastructure templateTemplate catalog
Understand how templates are structuredTemplate strategy
Author another infrastructure templateTemplate authoring
Look up commands or schemasReference

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 systemArchitectureRelease target
Linuxx86_64x86_64-unknown-linux-gnu
LinuxARM64aarch64-unknown-linux-gnu
macOSIntelx86_64-apple-darwin
macOSApple Siliconaarch64-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:

ainfra --version

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

Read outputs and configure hosts

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:

  1. InfrastructureTemplate declares engine locations, contract files, capabilities, and mandatory security invariants.
  2. TemplateInput carries non-secret configuration and references to credentials or ignored local configuration.
  3. 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

LayerWhat it proves
JSON SchemaDocument shape, version, enums, and unknown-field rejection
Policy checksCross-field security invariants
OpenTofu validationProvider configuration and expression correctness
CheckovKnown infrastructure-policy findings
GitleaksRepository secret patterns
Plan assertionsThe proposed resource graph matches safety expectations
Ansible checksSyntax, lint, check mode, and idempotence
Disposable live testProvider 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:

  1. preserve the original checkout, .ainfra/, state, and backend metadata;
  2. identify the exact backend, backend version, and state lineage through operator-reviewed backend-native tooling;
  3. identify the operator authorizing recovery and record a reviewed direct OpenTofu command;
  4. compare tofu state list with provider-owned resources and ownership labels;
  5. initialize a new ainfra project only after its configuration references the same verified backend and intended environment;
  6. create and review a fresh project-bound plan—never reuse a legacy plan ID;
  7. 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:

  1. The ainfra wrapper owns discovery, contract validation, reviewed-plan binding, sanitized outputs, and ownership-scoped destruction.
  2. A template owns provider resources, host configuration, supported images, capabilities, and provider-specific defaults.
  3. 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.

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:

ainfra --version

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.

4. Create the environment input

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.

Input-field choices

FieldConfigure it as follows
metadata.environmentA unique lowercase environment name. It becomes part of resource names and ownership labels.
metadata.disposableKeep true when using local-disposable state.
provider.locationChoose fsn1, nbg1, or hel1, the European locations supported by this contract.
projectTokenRefKeep the HCLOUD_TOKEN environment reference; never paste the token value.
controlPlaneCountExactly 1 in the current contract.
workerCount0 for the smallest tutorial, or the required non-negative count.
imagedebian-13, currently the only validated image.
serverTypecx23 in the example; confirm availability and price in the selected location.
privateCidrA private IPv4 range that does not overlap networks used to reach the environment.
publicIPv4true for direct tutorial access; this can incur a separate charge.
publicIPv6Keep false unless IPv6 access is explicitly required and tested.
managementIngressCidrsOnly trusted operator or VPN egress CIDRs. These permit TCP/22 when a public address is enabled.
workloadIngressCidrsKeep empty; the baseline does not install a workload.
adminSshPublicKeysOne or more Ed25519 public keys, never private keys.
state.modelocal-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.

5. Validate input and readiness

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.

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.

Configure hosts

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.

Direct-tool escape hatch

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.

Install security tools

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:

  1. Copy templates/hetzner-kubernetes-baseline/inputs/example.input.yaml outside the template’s tracked examples.
  2. Keep metadata.template: hetzner-kubernetes-baseline.
  3. Change only documented input fields.
  4. Keep credentials as references or environment variables, never values.
  5. Validate the template and input together.
  6. 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:

  1. Rename the directory using lowercase letters, numbers, and hyphens.
  2. Set the same identifier in ainfra-template.yaml at metadata.name.
  3. Reset metadata.version for the new template and describe its purpose.
  4. Declare the supported ainfra wrapper range.
  5. Point both engine working directories to paths inside the template.
  6. Reference only shared schemas that are direct children of schemas/.
  7. Replace example inputs, embedded template identifiers, output metadata, provider resources, and README content.
  8. Declare only capabilities supported by src/template.rs.
  9. Pin OpenTofu providers and Ansible collections and roles.
  10. 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.

configure

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.

ContractSourcePurpose
InfrastructureTemplate/v1alpha1schemas/template-manifest.v1alpha1.jsonTemplate engines, paths, capabilities, and invariants
TemplateInput/v1alpha1schemas/template-input.v1alpha1.jsonNon-secret operator intent and credential references
InfrastructureOutput/v1alpha1schemas/template-output.v1alpha1.jsonStable, non-secret handoff to downstream systems
ainfra.plan/v1alpha1schemas/plan-record.v1alpha1.jsonExact reviewed-plan binding for lifecycle authorization
ainfra.plan/v1alpha2schemas/plan-record.v1alpha2.jsonProject-bound lifecycle authorization
ainfra.run/v1alpha2schemas/run-record.v1alpha2.jsonImmutable run identity
ainfra.run-event/v1alpha1schemas/run-event.v1alpha1.jsonAppend-only lifecycle evidence
ainfra.status/v1alpha1schemas/status.v1alpha1.jsonSanitized local status output
ainfra.legacy-inspection/v1alpha1schemas/legacy-inspection.v1alpha1.jsonRead-only legacy evidence report

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:

ainfra validate DOCUMENT

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.

TemplateSourcePurposeStatus
hetzner-kubernetes-baselinetemplates/hetzner-kubernetes-baselinePrivate-networked, hardened Debian 13 hosts prepared for a later Kubernetes installationInitial 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:

  1. cloud-init schema validation;
  2. OpenTofu plan and apply;
  3. deterministic SSH host identity verification;
  4. Ansible check mode;
  5. Ansible apply;
  6. a second apply with zero changes;
  7. destroy;
  8. 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.
RequirementEvidence
Versioned strict contractsSchema and positive/negative fixture tests
Visible wrapper commandsCLI, runner, lifecycle, and adapter tests
No template hardcodingContract-driven discovery and fixtures
Pinned Hetzner templateProvider lock, image enum, validation
Secure SSH and keysSchema, policy, plan, and host tests
Safe stateBackend capability validation
Secret-free outputStrict schema, redaction, Gitleaks
Local validation gatesscripts/validate-all, scripts/test-all
Standalone Rust productNo Python package; installed-shell no-tool test
Disposable lifecycleApproved live apply, idempotence, and teardown
Clear portfolio boundariesREADME and architecture review
No unsafe donor behaviorNegative policy and plan tests
No GitHub workflowsRepository-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.

Promotion flow

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.