scaffold.yaml Schema Reference
The full AtmosScaffoldConfig manifest schema (pkg/project/config.ScaffoldSpec,
JSON Schema generated at pkg/datafetcher/schema/scaffold/scaffold-config/1.0.json).
Envelope
apiVersion: atmos/v1kind: AtmosScaffoldConfigmetadata:name: my-template # requireddescription: ...author: ...version: ...spec:source: ... # provenance; written to project records, ignored in templatesbaseRef: ... # 3-way-merge base ref; written to project recordsdelimiters: ["[[", "]]"] # optional: override the default {{ }} Go template delimitersfields: [...] # the questionnairevalues: {...} # preset/default answer values keyed by field namefiles: [...] # optional conditional-generation overlayhooks: {...} # optional pre/post-generate hooks
spec.fields[] — questionnaire
- name: component_name # required; used as the template variable (.Config.component_name)type: input # input|text|string|select|multiselect|confirm|bool|boolean|computedlabel: Component name # short prompt label (falls back to name if omitted)description: ... # longer help text shown with the promptrequired: truedefault: my-defaultoptions: [a, b, c] # select/multiselect only; see below for {label,value}/dynamic formsplaceholder: e.g. vpc # input fields onlyvalidation:pattern: '^[a-z][a-z0-9-]*$'message: "Must be lowercase alphanumeric with hyphens"when: "answers.some_earlier_field == true" # optional; gates whether this field is shown
Field name uniqueness is enforced — a duplicate name fails to load
(ErrDuplicateScaffoldFieldName) rather than silently dropping an answer.
type: computed — derived fields
A computed field is never prompted for and can't be set with --set
(ErrScaffoldComputedFieldNotSettable) — its value: is either a
Go-template expression deriving it from other answers (evaluated with the
same answers.* binding options:'s dynamic form uses), or a literal of
any type used as-is with no rendering. A string is only treated as an
expression when it actually contains a template action; a plain string
with none is a literal too:
- name: regionstype: multiselectoptions: [us-east-1, us-west-2, eu-west-1]- name: primary_region_selecttype: selectoptions: answers.regionswhen: "size(answers.regions) > 1"- name: primary_regiontype: computedvalue: "{{ ternary answers.primary_region_select (index answers.regions 0) (gt (len answers.regions) 1) }}"
Rules, enforced at scaffold-load time (ErrScaffoldComputedFieldInvalid):
value:is required on acomputedfield, and only valid on acomputedfield.required:anddefault:are both rejected on acomputedfield — it's always self-supplied, andvalue:already determines its value.- A
computedfield may reference any regular field's answer, or an earlier-declaredcomputedfield's own result — computed fields are evaluated once, inspec.fields[]declaration order, after every regular field's answer is final (prompted,--set, or defaulted). Unlike anoptions:dot-path forward reference, acomputedfield's own name is always statically known at load time, so referencing itself or a later-declaredcomputedfield is a load-time error rather than silently resolving to no value — the alternative would be a nil silently interpolated into generated file content as the literal string<no value>. - Because computed fields are only evaluated after the interactive form completes, a
regular field's
when:cannot depend on a computed field's result — only the reverse (a computed field depending on a regular field) works. Referencing a computed field from a regular field'swhen:is a load-time error (ErrScaffoldComputedFieldInvalid), the same wayoptions:referencing a computed field is (ErrScaffoldFieldOptionsInvalid) —options:resolves at form-build/validation time, before any computed field has a value, for the same reason. Amatrix:axis, by contrast, expands afterComputeFieldsruns, so referencing a computed field there works correctly. - The resolved value lands in
.Config.<name>exactly like any other field, so it's usable everywhere.Configis (file content,target:,matrix:axes, and other computed fields) — exceptoptions:, per the point above. - A
computedfield'svalue:must be pure (deterministic given the same answers). Interactive generation with no target directory given evaluates every computed field twice: once (against a throwaway temp directory) to suggest a target directory name, again against the final answers to generate files. An impure expression (the current time, an environment variable, a remote fetch not guaranteed to return the same content twice) can compute a different value each time, so the suggested directory and the generated files can disagree.
options: — static, label/value, or dynamic
options: (select/multiselect only) accepts four shapes:
- A plain string list — label and value are the same:
options: [dev, staging, prod]
- A list of
{label, value}objects —valueis required (a non-empty string);labelis optional and defaults tovaluewhen omitted. Onlyvalueever reachesanswers/templates/when:—labelis presentation-only:options:- label: Developmentvalue: dev- label: Productionvalue: prod - A dot-path string,
answers.<name>— resolves against an earlier field's answer, aspec.valuespreset, or a--set-supplied value never declared as a field at all. Must resolve to a list-shaped value (amultiselectanswer, or a structured[]string/[]anyof strings). Mirrorsspec.files[].matrixaxis dot-path resolution exactly — sameanswers.prefix convention, same underlying resolver:options: answers.envs - A Go-template expression (recognized by containing the scaffold's configured left delimiter,
{{by default) — computes the list from nested/structured answer data:options: '{{ splitList "," answers.csv_field }}'
Both dynamic forms (dot-path and template expression) resolve correctly once the referenced
earlier field has been answered — interactively (fields prompt one at a time, so a later field is
only ever shown after the ones before it) or headlessly against --set/--defaults-supplied
values. Unlike
an earlier, now-removed load-time check, a dot-path's root name is not validated against field
declaration order at load time — load time can't distinguish a genuine forward/self-reference
mistake from a legitimate spec.values preset or --set-supplied value that was never declared
as a field at all. A forward reference simply resolves to no options at that point (disabling the
membership check for that field, not erroring); a self-reference is tautologically valid (the
field's own final answer is what gets checked against itself). See
validateFieldOptionsSource/resolveFieldOptionsFromAnswers in
pkg/project/config/validation.go.
Label recovery: when a direct single-segment dot-path (answers.<name>, not a deeper path
like answers.nested.envs, and not a template expression) sources from a field whose own
options: used {label, value} pairs, those labels are recovered for the filtered subset of
values present in the referenced answer — a value present in the answer but absent from the
source field's own options list falls back to label == value rather than erroring or being
dropped. Label recovery does not propagate through a chained dynamic reference (a dot-path field
sourcing from another dot-path field) or through the template-expression form — both always yield
label == value.
when: is evaluated by building one huh.Group per field (huh's WithHideFunc)
against a snapshot of every other field's current answer at render time — so a
when: can only meaningfully reference fields declared earlier in the fields:
list. In non-interactive mode (--defaults/no TTY), the same when: check gates
whether a hidden required field is treated as "missing" (MissingRequiredValues), so
--set doesn't need a value for a conditionally-hidden field.
spec.files[] — conditional generation overlay
- path: stacks/deploy/dev.yaml # required; matched against the file's discovered pathwhen: "'dev' in answers.environments"
Files not listed in spec.files: always generate (subject to the pre-existing
path-templating sentinel-skip behavior). This overlay does not declare which files
exist — the template's file tree does that; it only gates whether an already-discovered
file gets written.
path: may be a glob pattern (doublestar syntax) instead of a literal path, matched
against every discovered file:
- path: "docs/legacy/**" # *, ?, [...], ** (any depth incl. zero), {a,b}when: "answers.include_legacy_docs == true"
- Always forward slashes; a backslash in the pattern is normalized to
/regardless of authoring/runtime OS (pkg/utils.NormalizeGlobPattern). - A malformed pattern (unclosed
[/{) fails scaffold load andatmos scaffold validate(ErrScaffoldFilePathPatternInvalid) — not just a silent permanent non-match at generation time. - When multiple entries'
path:match the same file, the last declared entry wins (.gitignore/CODEOWNERSprecedence: write broad patterns first, specific overrides after — a wrong-order override is a silent no-op, not a load error).
spec.files[].matrix — dynamic file generation
- path: templates/deploy.yamltarget: "deploy/{{ .matrix.environment }}/{{ .matrix.region }}.yaml" # required when matrix is setmatrix:environment: answers.environments # dot-path into an already list-shaped answerregion: [us-east-1, us-west-2] # literal listwhen: "matrix.region in answers.environments[matrix.environment].regions"
Expands this one file entry into one generated file per resolved combination of every
axis's values (their full Cartesian product, sorted per axis for deterministic output)
— the same shape the workflow matrix: step uses. target: is required (a single
path: can't serve as more than one output) and rendered once per combination.
Each axis's value is one of:
- a literal list of strings
- a string starting with
answers., a dot-path into an already list-shaped answer - a Go-template expression (any string containing
{{) computing the list from nested/structured or free-text answer data (viacollectKeys,splitList, or any other Sprig/Gomplate function — seeatmos-templates)
when: gets a matrix CEL variable alongside answers, evaluated once per resolved
combination to prune ones that don't apply. The resolved combination is also available
as .matrix.<axis> in target: and the file's own rendered content.
When path: is a glob matching more than one file, matrix: is resolved once per
path: (cached, not recomputed per matched file — so a non-deterministic axis
expression, e.g. Sprig's randAlphaNum, still resolves the same value across every
matched file for one combination) and every matched file gets its own output per
combination. Two additional template variables, available in target: and content
alongside .matrix.<axis> regardless of whether path: is a glob:
.file.Path— the currently matched file's own discovered path..file.RelPath—.file.Pathwith the matching entry's glob literal prefix stripped (equal to.file.Pathwhenpath:has no glob metacharacter).
target: must reference .file.Path or .file.RelPath whenever its path: matches
more than one file, or every matched file renders to the same output path for a given
combination — checked deterministically before any file in the run is written
(ErrScaffoldMatrixTargetMissingFileContext), not only once a collision is reached
mid-run. .file.* is Go-template-only; it is not exposed to CEL when:.
spec.hooks — step-backed hooks
hooks:<hook-name>:events: [before.scaffold.generate, after.scaffold.generate] # default: bothkind: step # or: stepswhen: "..." # default (empty): success-only, mirroring stack-level hookstype: shell # kind: step only — a registered step typewith: # kind: step: one step's params; kind: steps: an ordered listcommand: "git add ."
This reuses pkg/hooks.Hook field-for-field (same struct as stack-level lifecycle
hooks) — see atmos-hooks for the full vocabulary. Only kind: step/kind: steps are
implemented for scaffold hooks; command/store/git kinds are stack/component-only
today (their execution engines assume ExecContext.Info, which scaffold generation
doesn't have).
before.scaffold.generate hooks run once, right after the form is filled in and before
any file is written — a failure aborts before any write happens (no rollback needed).
after.scaffold.generate hooks run once after the file loop completes and the project
record (.atmos/scaffold.yaml) is saved; they still get a chance to run on a failed
generation if their when: explicitly opts in (when: always/when: failure) — the
implicit-success default skips them on failure, matching stack-level hooks.
Why when: can't use the {all:/any:/not:} map form here
pkg/condition.Condition's Go type has no exported fields (by design — it's a small
AST wrapper), so when it's reflected into JSON Schema, invopop emits a schema with
additionalProperties: false alongside the oneOf branches. If object were included
as an allowed branch, a value like when: {all: [ci, success]} would satisfy the
oneOf's object branch but then immediately fail the sibling additionalProperties: false (since all isn't a declared JSON Schema property) — a real, confirmed
contradiction, not a hypothetical one. Scaffold's when: schema therefore only allows
string (predicate keyword or CEL expression) and array (implicit all) — use CEL's
&&/||/! operators for compound logic instead of the map form.
spec.values — preset/default values
values:cloud_provider: aws # overrides a field's own `default:`, still overridden by --set
Precedence (lowest to highest): field default: → spec.values → --set/interactive
answer.