Declared gateway deploy (ign deploy)
ign deploy operates on a declared repo: a git tree that says what a
gateway should hold, kept separate from any live gateway. Phase 21 ships
the first verb, render, which turns the declared tree into the exact
bytes the gateway keeps on disk. Later verbs (plan, pull, verify,
apply, rollback, projects) append their own subsections below.
The repo layout:
targets/<env>.yaml # where an environment deploys
gateways/<name>/manifest.yaml # what the gateway tree may contain
gateways/<name>/resources/<module>/<type>[/<name>]/… # the resource files
<module>/<type> is the gateway's own resource collection (for example
ignition/database-translator); a <name> directory is one named
resource, and a type with no <name> level is a singleton (for example
ignition/general-alarm-settings).
--targets DIR (D-07, D-10, D-14) is accepted by render, plan, pull,
verify, apply, rollback and projects; it defaults to --repo, and
without it every verb reads and writes exactly what it did before. The reviewed
targets/<ENV>.yaml files and the known_hosts files they name live only in
the shared IaC repository (WhiskeyHouse/whk-onprem-infra-iac, folder
ignition/), so a gateway-config repo such as WHK-Global holds no targets/
directory and points --targets at a checkout of that folder. DIR roots the
target file and what it names: targets/<ENV>.yaml is read from DIR, and a
relative known_hosts or relative local.data_dir in it resolves against
DIR (a known_hosts path that leaves DIR is refused when the target is
read). Everything else stays under --repo: the gateways/<name>/ tree,
render --out, the tree pull writes, and the .ign/ run directory of
apply and rollback. WHK-Global runs deploy apply for its tag providers
and deploy projects for its project as separate jobs (D-10), each with the
same --targets checkout.
deploy render
ign deploy render --env <ENV> [--repo DIR] [--targets DIR] [--out DIR] is offline. It opens no
network connection, resolves no profile and reads no credential, so the
envelope carries profile: null and it needs no config file. Without
--out it writes nothing to disk; --out DIR writes the rendered tree (see
Writing the rendered tree). --repo
defaults to the current directory.
--env resolution. --env dev selects targets/dev.yaml; that file's
gateway field names gateways/<gateway>/, whose manifest.yaml is read
next. --env must be a safe single path component (not empty, no / or
\, not ./.., not dot-leading); anything else exits 2 invalid_input
before any path is built. A missing targets/<env>.yaml, a missing gateway
directory or manifest, or a symlink anywhere in the tree read exits 2
deploy_tree_invalid naming the repo-relative path. Symlinks are never
followed.
manifest.yaml (schema_version: 1; unknown fields are refused with
file:line:column, missing or duplicate fields name the file only):
| Field | Meaning |
|---|---|
schema_version | required, always 1 |
standards_version | optional free-form label; informational |
types | required list of allowed module/type resource types |
tag_providers | optional list of tag provider directory names |
projects | optional list of pinned project artifacts (name, version, sha256, artifact) |
verify.tag_probes | optional list of tag paths read back after a deploy |
targets/<env>.yaml (schema_version: 1, same strictness):
| Field | Meaning |
|---|---|
schema_version | required, always 1 |
gateway | required directory name under gateways/ |
mode | required deployment mode name |
nodes.master, nodes.backup | master required, backup optional; each has profile (an ign profile NAME, never a secret) and an optional ssh block. The two nodes are two gateways: nodes.backup.profile equal to nodes.master.profile, the same local.data_dir on both, or the same ssh host, port and data_dir on both is refused with deploy_tree_invalid (exit 2) when the target is read, before any gateway or host is contacted (otherwise deploy verify --node backup would read the master as the backup). Two different profiles that point at one URL are not detected |
nodes.*.ssh | host, port, user, key_file or key_env (a file path or an env var NAME, never both and never a key), os (windows or linux), data_dir, known_hosts, connect_timeout_s, op_timeout_s |
nodes.*.ssh.known_hosts | required: a repo-relative path (no leading /, no .., no backslash) to the reviewed known_hosts file that pins the host key. ign copies it into a private temp directory and gives ssh that copy with StrictHostKeyChecking=yes; the runner's own ~/.ssh/known_hosts is never read |
nodes.*.ssh.connect_timeout_s | optional, 1..120, default 10: ssh ConnectTimeout in seconds |
nodes.*.ssh.op_timeout_s | optional, 1..3600, default 1050: one ssh or sftp call may run this long before it is killed (transport_failed). The default is three times the larger p95 of a measured read and commit of a 152-file tree over the internet to a Windows host (348 s), rounded up to 30 s; a tree ten times that size needs a larger value and is not covered by the default |
nodes.*.gateway_name | optional: the gateway's system name. When set, deploy apply refuses (deploy_preflight_failed, check wrong_target) unless the gateway's REST name equals it; on the master the REST name must also equal the host's systemName whether or not this is set. Not empty, no control characters. Only nodes.master is compared today; a value on nodes.backup is validated and not yet used |
nodes.*.local | data_dir: the gateway data directory on the machine running ign (rigs, tests, a runner on the gateway host). A relative value resolves against --repo. Mutually exclusive with ssh |
The allowlist (D-02). Only the module/type directories listed in the
manifest types are rendered. A type directory that is not listed is
refused, not skipped: exit 2 deploy_tree_invalid naming
gateways/<name>/resources/<module>/<type>/, with every such directory
reported together. A file directly under resources/ or
resources/<module>/ is refused the same way.
Rendered bytes. Every file the render does not need to change is
rendered byte for byte. Paths are ordered by the UTF-8 bytes of the
/-joined collection path (<module>/<type>/…, the repo path with
gateways/<name>/resources/ removed), and file contents are compared as raw
bytes; no Unicode normalization or case folding is applied to either.
render_sha256 is the lowercase hex SHA-256 of a listing with one entry
per rendered file, in byte-sorted collection-path order:
<collection path> NUL <lowercase hex sha256 of the file bytes> LF
NUL is the byte 0x00 and LF is 0x0a. The digest is stable across
runs and operating systems. An allowlist that matches no files renders
files: 0 with the digest of the empty listing,
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, exit 0,
and the warning render produced no files.
Data keys. With --json the envelope's data carries, in this order,
env, gateway, mode, out (the --out path as given, null without --out), files,
bytes, render_sha256, resources, tag_providers, uuids_minted,
opaque_files and warnings. resources is ordered by (module, type,
name) bytes with singletons (name null) first, each entry
{module, type, name, files} with files relative to the resource
directory; the other arrays are in byte order, and warnings are in
pipeline order. Keys are only ever added, never renamed. Human output is
rendered gateways/<name> for env <env> (mode <mode>): <n> files, <n> bytes
and render_sha256 <hex> on stdout (plus wrote <n> files to <dir> as a
third line with --out), with each warning on stderr as warning: …. A
refusal leaves stdout empty.
Over MCP. deploy render is in the ign mcp serve tool catalog as
deploy_render. It reads the repo and writes only through --out, whose
ladder (next section) is the safety, so the tool is not guarded and takes no
confirm argument.
Writing the rendered tree (--out)
--out DIR writes every rendered file under DIR at its collection path
(<module>/<type>/…), byte for byte, plus a marker DIR/render.json. Nothing
is written unless the whole render succeeded, so a refused render (policy,
Gson gate, uuid, lint or secret) creates nothing: an absent DIR stays absent
(no ancestor is created either) and an empty DIR stays empty.
DIR is accepted only when it is one of:
- absent: it and any missing parents are created;
- an empty directory;
- exactly a previous render of the same gateway and environment: every
regular file in it is listed in its
render.jsonwith an unchanged SHA-256, every listed file is present, there is no directory without a listed file, and the new render still produces every listed file. Listed files are then replaced with the new bytes.
Anything else is refused as invalid_input (exit 2) with the reason and the
first offending path, and nothing is written: an unrelated non-empty
directory, a directory without a render.json (including one left by an
interrupted write), a marker of another gateway or environment or with an
unknown schema_version, a file edited since it was rendered, an unlisted
file, a listed file the new render dropped, a regular file at DIR, a
symlink at DIR or anywhere under it, and any special file. Render never
follows or creates links.
Render never deletes a file, and it never overwrites a file that is not
listed, unchanged, in a prior render.json of the same gateway and
environment. New files are created exclusively (a second writer racing for the
same new directory loses and is refused); replaced files and the marker are
written to <name>.ign-render-tmp and renamed into place. The marker is
written last, so an interrupted write leaves no valid marker, and the
next render into that directory is refused until you remove it or choose
another. This ladder is the safety for the unguarded MCP tool.
render.json is ign's own file, not a gateway file, and is not part of
render_sha256. It is pretty-printed JSON with one trailing LF:
{
"schema_version": 1,
"gateway": "<gateway directory name>",
"env": "<--env>",
"mode": "<target mode>",
"render_sha256": "<same value as the envelope>",
"files": [{ "path": "<collection path>", "sha256": "<hex>", "bytes": 123 }]
}
files is sorted by path bytes. Later deploy verbs read this marker, so
unknown fields are refused and its shape is versioned by schema_version.
With --json, data.out holds the path exactly as given.
Byte fidelity
Files pass through byte for byte. Render rewrites a .json file only when
write(parse(bytes)) == bytes, where the writer is the gateway's own format:
members in source order (never sorted), 2-space indent, ": " between
key and value, {} and [] for empty containers, no trailing newline, the
HTML-safe escapes < > & = ' as < > & =
', U+2028 and U+2029 escaped, other non-ASCII as raw UTF-8, and every
number kept exactly as written (2.50, 1.0E10 and integers beyond 2^53
survive). A .json file that parses but is not in that format (a trailing
newline, a compact one-liner, other indentation) is opaque: it is listed
in opaque_files and render never mutates it.
JSON that render cannot parse is refused, not copied blind, because render
must inspect it for secrets: exit 2 deploy_tree_invalid naming each
gateways/<name>/… source path, with all such files reported together.
Refused are invalid UTF-8, a byte-order mark, a syntax error, nesting deeper
than 128 levels, a key repeated within one object, and a file over the
16 MiB (16,777,216 bytes) per-JSON cap. The message carries positions and
key names, never values.
Environments and overlays
gateways/<name>/modes/<env>/ is the overlay for environment <env>. It
mirrors the base layout: modes/<env>/resources/<module>/<type>/… and
modes/<env>/tags/<provider>/<tag type>/… (anything else directly under
modes/<env>/ is refused). A file in the overlay replaces the base file at
the same path whole (there is no merging of keys inside a file), or is
added when the base has no file there; a base file with no overlay
counterpart is rendered unchanged. Only the directory named for the --env
being rendered is opened: another environment's modes/<other>/ tree is never
read, so content in it (including content that would be refused) cannot affect
this render. A finding on an overlay file names the overlay path
(gateways/<name>/modes/<env>/…).
Overlays can only add or replace. A base resource cannot be removed for one
environment; keep a resource that differs per environment out of the base
tree and add it from each modes/<env>/ instead. This is a documented
limitation (SF-7), not an oversight.
Tags layout
Tags live under gateways/<name>/tags/<provider>/<tag type>/…, where <tag type> is one of tag-provider, tag-group, tag-type-definition or
tag-definition. Render maps each file to the collection path the gateway
holds on disk, as captured against an 8.3.6 gateway (decision CD5):
tags/<provider>/<type>/<rest> -> ignition/<type>/<provider>/<rest>
For example tags/default/tag-group/Default/config.json renders as
ignition/tag-group/default/Default/config.json, and a folder of tags
tags/default/tag-definition/Plant/tags.json as
ignition/tag-definition/default/Plant/tags.json. Folders nest as directories.
Every provider directory must be listed in the manifest tag_providers;
a provider that is not listed is refused (naming tags/<provider>/), and the
tag_providers data key lists the providers that rendered at least one file.
Tag types are not declared under resources/ (and do not need a manifest
types entry); resources/ignition/<tag type>/… is refused.
Three layouts were not exercised by the capture and so stay refused: a
tags.json or udts.json directly at the root of tag-definition
(root-level tags; put tags in a folder), folders nested under
tag-type-definition, and a non-default collection. Two source files that map
to the same collection path are refused, naming both.
What render refuses
Render is fail-closed: a defect is a refusal (exit 2 deploy_tree_invalid),
never a skip, and every defect found in one render is reported together, each
naming the repo path to open (an overlay path when the overlay supplied it).
Refused:
- a type directory under
resources/that is not in the manifesttypes(D-02); - the security types
ignition/security-properties,ignition/security-levels,ignition/api-tokenandignition/user-source, even when the manifest lists them: rendering them can lock an administrator out of the gateway, so they stay gateway-owned. A manifesttypesentry for one of them is refused too, namingmanifest.yaml; config-mode.jsonat any depth, in the base or an overlay: the gateway writes it itself;- any path component that starts with
.(.DS_Store,.hidden/,.env). A file named.gitkeepis the one exception: it is skipped at any depth and never rendered or listed. Only the first character decides, so a module directory such ascom.inductiveautomation.perspectiveis fine; - a tag provider directory not listed in the manifest
tag_providers, a directory undertags/<provider>/that is not one of the four tag types, and tag types placed underresources/ignition/; - two source files that map to one collection path;
- symlinks, special files and non-UTF-8 names anywhere in the walked tree;
- unparseable JSON (see Byte fidelity);
- a resource type with no render rule, in the manifest
typesor in the tree (see Resource identity): only types backed by evidence render; - a singleton type with a name directory, or a named type with a file directly under its type directory;
- an invalid, or missing where required,
resource.jsonorattributes.uuid(see Resource identity). - an Embedded secret, a plaintext value at a secret-typed schema path, or a
plaintext
password/secretstring in an undescribed module type (see Secrets); this isembedded_secret, exit 2. - a Windows-illegal name, two siblings that differ only by case, a path over the Windows budget, or a carriage return in a text member (see Windows safety and line endings).
Resource identity (attributes.uuid)
A named resource whose resource.json has no attributes.uuid is silently
never loaded by the gateway, so render gives every new named resource one.
The uuid is deterministic (RFC 9562 version 8, built on SHA-256):
SHA-256( "ign-deploy:uuid:v1" NUL <gateway> NUL <module> "/" <type> "/" <name> )
Take the first 16 bytes, set the version nibble to 8 and the two top bits of
the variant byte to 10, and print lowercase 8-4-4-4-12 hex. NUL is the
byte 0x00; <gateway> is the gateways/<name> directory name. The inputs
are the exact UTF-8 bytes of each name: no normalization and no case folding,
so Pump and pump, or NFC and NFD spellings, get different uuids. The
environment is not an input, so one resource keeps one identity across a
gateway's environments; the gateway is, so two gateway trees never share
ids. An existing uuid is never recomputed. The derivation was checked against
a live Ignition 8.3.6 gateway, which loaded a hand-written resource carrying
the uuid this formula gives (decision CD1 in
.planning/phases/21-deploy-render/21-LIVE-CAPTURES.md).
Every rendered resource type has one of three rules, taken from the sanitized gateway corpus and from resources created on an 8.3.6 gateway:
| Rule | Layout | attributes.uuid |
|---|---|---|
| Singleton | files directly under <module>/<type>/ | none; a resource.json that carries one, or is missing, is refused |
| Named | one directory per resource | kept if present; derived and inserted if absent |
Named, no uuid (ignition/store-and-forward-engine, com.inductiveautomation.perspective/themes) | one directory per resource | never derived or inserted; a resource.json is required; an existing valid uuid is kept |
What render does for a Named resource:
resource.jsonpresent with a validattributes.uuid: kept byte for byte. An empty,null, non-string or non-8-4-4-4-12 value is refused naming the file; it is never replaced.resource.jsonpresent without one, in the gateway's writer format: the uuid is inserted intoattributesright afterlastModification(andlegacyIdwhen present) and the rest of the file is unchanged. Aresource.jsonthat is not in the gateway's writer format (see Byte fidelity) is refused instead of rewritten; add the uuid yourself or re-pull the resource.- no
resource.json: one is written in the gateway's own shape, withscopefrom the type's rule,version1,restrictedfalse,overridabletrue,fileslisting the resource's files in byte order, andattributesholding the uuid andenabled: true. The gateway loads such a file withoutlastModificationor a signature (decision CD2). The data keyuuids_mintedlists these resources asmodule/type/name, in byte order.
Fail-closed. A type with no rule is refused, whether it is a manifest
types entry or a directory in the tree: only types with corpus or capture
evidence render. The four tag types follow the tags/ layout instead and are
not subject to these rules.
Secrets
Render never ships a secret. A secret field is accepted only when it is
absent, null, or a Referenced secret, which points at a secret held by
a gateway secret provider:
{ "type": "Referenced", "data": { "providerName": "vault", "secretName": "db-pass" } }
A Referenced secret carries a provider and a secret name, not a value, so
it is safe to commit (decision CD7). Everything else is refused as
embedded_secret (exit 2), and the error names <source file>: <json path>
and the kind of defect, never a value.
The accepted shape is exact. A Referenced secret is an object with exactly two
members: type, the string "Referenced", and data. data holds exactly
the two string members providerName and secretName. Member order does not
matter; the gateway writes data then type (CD7). Any other shape is
not a Referenced secret: an extra member inside data or beside it, a
missing member, a wrong-case name or a non-string name. Such a value is
refused as (plaintext) at a schema path. Elsewhere it is scanned like any other object,
so the key-name rule (or its warning, in a described type) applies to its
members.
The checks, in the order they apply:
-
Embedded anywhere. Any JSON object with
"type": "Embedded"and an objectdata, at any depth of any.jsonmember of any resource or tag file, is refused.Embeddedis how a gateway stores an encrypted secret inline; the ciphertext is tied to one gateway and must not be committed. The path reads<file>: password (embedded). -
Plaintext at a schema path. For the ten resource types whose 8.3.6 OpenAPI schema has secret-typed fields,
config.jsonmust hold absent,nullor a well-formed Referenced at each path; a string (even an empty one) or any other value is refused as(plaintext):Type Secret paths in config.jsoncom.inductiveautomation.alarm-notification/alarm-notification-profilesettings.settings.password,settings.twoWayPop3.pop3Password,settings.voipGateway.passwordcom.inductiveautomation.opcua/devicesettings.advanced.passwordignition/database-connectionpasswordignition/email-profilesettings.settings.passwordignition/identity-providersettings.clientSecretignition/oauth2-clientclientSecretignition/opc-connectionsettings.authentication.password,settings.authentication.privateKeyPem,settings.security.keyStoreAliasPasswordignition/service-connectorsettings.password,settings.ssl.keyPassword,settings.ssl.keystore.key,settings.ssl.keystore.password,settings.ssl.truststore.passwordignition/system-propertieserrorReportPasswordignition/user-sourcesettings.connectionPassword,settings.ldap.connectionPassword(a denied type; listed for completeness) -
Plaintext by key name, in module types the OpenAPI does not describe. For a type outside the 8.3.6 OpenAPI (for example the Cirrus Link MQTT types), a non-empty string under a key whose name contains
passwordorsecret(ASCII, any case) inconfig.jsonis refused as(plaintext, by key name). ThesecretNameof a well-formed Referenced object is a name, not a secret, and is not checked. To fix a refusal, move the value into the gateway's secret provider and reference it as above. An empty string,null, a number and a boolean under such a key are fine. -
A warning, in described types. In a type the OpenAPI does describe, the schema is authoritative: its secret-typed fields are refused at their exact paths, and a string under a secret-looking key elsewhere (a field that is not secret-typed) only produces a warning naming the path. Render still succeeds.
The --json envelope, stderr, stdout and the warnings carry paths only. Tests
plant canary values in each of these cases and assert they appear nowhere.
Residual risk (DRND-07). Detection in an undescribed module type is by key
name, so a plaintext secret under another name (apiKey, token,
privateKey, credentials) is not detected. Conversely, a string field in
such a type whose name merely contains password or secret is refused, and
Phase 21 has no override: rename the field in the source only if the module
allows it, or keep that resource out of the declared tree. The corpus has no
such string value. When a secret and tree defects are found together, one
embedded_secret error lists the secret paths and then the other findings.
Tag files are scanned for Embedded objects only: they have no resource schema.
Windows safety and line endings
resources/ is shared by every environment, so a tree committed for a Linux
dev rig can later be deployed to a Windows production host. Render therefore
applies the Windows rules to every environment and refuses what NTFS would
reject at deploy time (D-04: fail-closed now; the real NTFS behaviour is
captured in Phase 22, so these rules are an approximation made deliberately
strict). The error names the offending component or file. Refused:
-
a path component (a directory or a file name) containing
<>:"|?*or a control character, ending in.or a space, longer than 255 UTF-16 code units, or whose stem (the part before the first.) isCON,PRN,AUX,NUL,COM1-COM9orLPT1-LPT9in any case. Themodeintargets/<env>.yamlis held to the same rules. An 8.3.6 gateway on Linux writes such a name encoded (a:basa%3ab, decision CD6); a name already encoded that way is legal and passes; -
two siblings (files or directories) whose names are equal once lower-cased, such as
Pumpandpump; every colliding sibling is named. Whole names are compared withstr::to_lowercase; -
a file whose Windows path is too long. A full path must be shorter than 248 UTF-16 code units, counted as:
data directory + \config\resources\ + mode + \ + path under resources/The data directory is the longest of the default
C:\Program Files\Inductive Automation\Ignition\data(51 characters) and thessh.data_dirof every Windows node in the target (a trailing separator is ignored; a Linux node never counts).\config\resources\is 18 characters. With the default directory and the modeproduction, a path underresources/of 167 units passes (total 247) and 168 is refused (248); -
a carriage return (0x0D) in a text member, naming the file, line and column. A text member has the extension
.json .css .yaml .yml .txt .md .xml .py .csv .properties .html .js .svg, or is any file whose bytes are valid UTF-8 with no NUL byte. Git and editors silently convert line endings, which would change the bytes the gateway receives, so render refuses CR instead of guessing. Binary members are exempt (D-01): a JDBC driver jar containing 0x0D bytes renders byte for byte.
Lengths count UTF-16 code units (a character outside the Basic Multilingual Plane counts as 2), not bytes or characters. Reserved names compare the ASCII-uppercased stem. A zero-byte non-JSON member renders as a zero-byte file; an empty directory contributes no file and no resource entry. These checks also apply to tag files, which use their mapped collection paths.
deploy plan
ign deploy plan --env <ENV> [--repo DIR] [--targets DIR] [--base DIR] [--diff] compares the rendered tree for one
environment with the live gateway files of nodes.master and reports what a
deploy would do. It is read-only: it writes nothing, takes no lock, opens
no gateway session and sends no REST request, so it needs no secret and no
reachable gateway; the envelope profile is the master node's profile name
from the target file, used as a label only. The host is read through
nodes.master.local (the gateway data directory on this machine) or
nodes.master.ssh. A master with neither block exits 2 deploy_tree_invalid
naming nodes.master; a host that cannot be read exits 4 transport_failed
naming nodes.master. A read failure is never treated as an empty gateway.
A journal blocks the plan. While an interrupted deploy apply has left its
journal.json on the host, deploy plan refuses with deploy_journal_present
(exit 6) before it reads the collections, and the message prints the recovery
command; see deploy rollback.
Exit 0 always. A plan that finds changes still exits 0 (the success
envelope carries data.summary.has_changes); only a refusal exits non-zero.
Basis and live_in (D-17). The plan reasons about units, not files:
one <module>/<type>/<name> resource, one singleton, or one tag folder. A
rendered unit is compared with its effective live copy: the copy in the
mode collection (config/resources/<mode>/) when the unit exists there, else
the copy in core, because the gateway overrides whole resources and tag
folders by name. live_in lists where live copies sit, the ordered subset of
["core", "mode"], so a same unit that is not yet in the mode collection is
still visible. Comparison is byte-exact: a whitespace-only change is
changed.
Statuses (D-06).
| Status | Meaning |
|---|---|
added | rendered, no effective live copy |
changed | rendered, the effective copy differs in file set or any byte |
removed | not rendered, and the declared-names ledger lists a file of the unit that still exists in the mode collection (the delete set) |
same | rendered, the effective copy has the same files, byte-equal |
unmanaged | live, of an allowlisted type or declared provider, not rendered; never deleted and never counted in has_changes |
core relation. core relates the core collection's copy of a unit to
the rendered unit: none (no core copy), identical, differs, hidden (a
tag folder defined in the mode collection whose core copy holds files the
rendered folder lacks; the gateway overrides these by folder, so those core
files are invisible) or shadows_removal (status removed while a core copy
exists: deleting from the mode collection will not take effect, because the
gateway keeps running the core copy; Phase 23 blocks an apply on it).
Data keys. With --json the envelope's data carries env, gateway,
mode, node (always master), transport (local or ssh),
render_sha256, plan_sha256, summary (added, changed, removed,
same, unmanaged, has_changes, drifted), entries, delete_set, base
and warnings. Each entry is
{key, module, type, name, status, live_in, core, destructive, destructive_reason, lockout_risk, drift, files, diff}
where files are {path, live_sha256, desired_sha256}; entries are in
unit-key byte order. delete_set is {"files": [...]}, byte-sorted.
drift, summary.drifted and base are null without --base; diff is
null without --diff. Keys are only ever added, never renamed. Entries carry
paths and hashes, never file bytes; the only file text in a plan is a --diff
section that passed the secret scan (see below).
Human output. The first line is
plan <gateway> (<env>, mode <mode>) via <transport>: <a> added, <c> changed, <r> removed, <s> same, <u> unmanaged,
then one line per entry whose status is not same
(<status> <key> core=<core> live_in=<a,b>, followed by
destructive=<reason>, lockout_risk and drift when set, and, with
--diff, the entry's diff text indented two spaces), then one
delete <path> line per delete-set file, then plan_sha256 <hex>. Each
warning goes to stderr as warning: ....
plan_sha256 (D-08). The lowercase hex SHA-256 of this UTF-8 text, byte
for byte. LF is 0x0A and NUL is 0x00:
ign-deploy:plan:v1 LF
render_sha256 NUL <render_sha256 hex> LF
E NUL <key> NUL <status> NUL <core> NUL <live_in joined by ","> LF
F NUL <collection path> NUL <live sha256 or -> NUL <desired sha256 or -> LF
D NUL <collection path> LF
One E line per entry that is not unmanaged, in unit-key byte order, each
followed by its F lines in path byte order; then one D line per delete-set
path in byte order (none without a ledger). status and core are the
serialized words. Drift, diff text and warnings are excluded on purpose, as
are unmanaged entries, so a review hash is reproducible and unrelated live
resources never invalidate it. Because the D lines carry the delete set, a
ledger that changes after review changes plan_sha256, so Phase 23's
--plan-sha256 gate refuses the stale plan.
The delete set (DPLN-04). delete_set.files is exactly the ledger's files
minus the rendered files, restricted to files that exist in the mode
collection. Plan reads the ledger once, never writes or rewrites it, and
never puts into the delete set a file the ledger does not list, a
config-mode.json, a dot-named path, a file under a type or tag provider the
manifest no longer allows (reported in warnings, never deleted), or a ledger
path that equals a rendered path under ASCII case folding (reported in
warnings: a case-insensitive host would delete the rendered file). Paths
compare as exact UTF-8 bytes otherwise. No ledger is a first deploy: an empty
delete set and no removed entries. A rendered unit that loses only some of
its ledger files is changed (those files are deleted, nothing else); a unit
that is not rendered and has delete-set files is removed; a live unit with
no delete-set file stays unmanaged.
Flags (DPLN-03). They derive only from status, delete-set membership and type, so the same input gives the same flags.
| Flag | Value |
|---|---|
destructive | true for every removed entry and every entry with delete-set files |
destructive_reason | null unless destructive: tag_data_loss (ignition/tag-provider, tag-group, tag-type-definition, tag-definition), connection_removed (ignition/database-connection, opc-connection, identity-provider), storage_removed (ignition/store-and-forward-engine, alarm-journal), otherwise resource_removed for removed and files_removed for changed |
lockout_risk | true for an added, changed or removed entry of ignition/identity-provider or ignition/security-zone; a same or unmanaged unit is never a lockout risk because the plan does not touch it |
The local/ collection. The gateway loads the local collection over the
mode collection. The plan does not model it: when a unit also exists in
config/resources/local/, the plan adds a warning that it does not describe
what the gateway runs for that unit.
The declared-names ledger
The ledger is an on-box record of the mode-collection files the last apply wrote for one gateway and mode. Plan reads it to learn which names this tool previously declared; it never writes it (only an apply does, Phase 23). Format v1 is one strict JSON object:
{
"schema_version": 1,
"gateway": "<target gateway>",
"mode": "<target mode>",
"render_sha256": "<64 lowercase hex>",
"run_id": "<[A-Za-z0-9._-]{1,128}>",
"files": ["<module>/<type>/..."]
}
- Location (Decision B, below):
ign-deploy/<gateway>/<mode>/ledger.json, below the service account's home directory for ansshnode and below the node's data directory for alocalnode. Never insideconfig/resources/, so the gateway never scans it. - Size and encoding: at most 8 MiB (8388608 bytes), strict UTF-8, never empty (a 0-byte file is refused). Unknown fields and repeated keys are refused; nesting is bounded.
files: collection-relative paths, byte-sorted and unique, with no per-file hashes (it is a name list, not a drift base)."files": []is valid. Each path has at least three components, no dot-named component, is notconfig-mode.json, is not a case-variant of another entry, and sits under a type the gateway describes (an OpenAPI resource type, or one of the four tag types underignition).- Absent means first deploy: no ledger gives an empty delete set.
- Refusals: a ledger that exists but is unreadable (a symlink, not a
regular file, a read error), empty, over the cap, not strict JSON, of
another
schema_version, naming a differentgatewayormodethan the target, unsorted or duplicated, or listing any path the rules above refuse, exits 6deploy_ledger_invalid, naming the ledger location and the problem. Plan never falls back to an empty delete set. The problem text names fields and listed paths only, never other file content.
SSH transport hardening
An ssh node is reached with the system ssh and sftp clients, started with
an argument vector and never through a shell string; host and user may not
start with -, hold whitespace or control characters, or contain @, and --
always precedes user@host. Every call pins, on the command line (so a
user's ~/.ssh/config cannot weaken them): BatchMode=yes,
StrictHostKeyChecking=yes, UserKnownHostsFile set to the copy of the
repo's known_hosts, GlobalKnownHostsFile set to the null device,
UpdateHostKeys=no, VerifyHostKeyDNS=no, public-key authentication only (no
password or keyboard-interactive prompts), no agent, X11 or port forwarding, no
local command, no control socket, ConnectTimeout from connect_timeout_s,
ServerAliveInterval=5, ServerAliveCountMax=3 and LogLevel=ERROR. Stdin is
closed after the call's input, a call that outlives op_timeout_s is killed,
and captured output is bounded; a failure keeps only the last five stderr
lines (at most 1 KiB, control characters and the private temp path removed).
The OpenSSH sftp client prints a newline or carriage return inside a file
name as is, so a directory of a Linux or macOS host is listed with ls -lan:
the client then prints every entry with the listed directory in front of its
name, whatever the server. A listing line without that prefix (the rest of a
name that holds a newline) is exit 4 transport_failed, never read as another
entry. The output is split on newlines alone, and one carriage return per line
is dropped only when every line ends in CRLF (the Win32-OpenSSH client), so a
name that holds a carriage return keeps it and is refused like any other
control character.
key_env names an environment variable holding the private key text. It
is normalised (CRLF to LF, one trailing newline), written with owner-only
permissions into a private temp directory (an owner-only ACL through icacls
on a Windows runner), passed as -i <file> with IdentitiesOnly=yes, and
deleted when the call set ends. The key never appears in an argument, an
error, the JSON envelope or a log line; an unset or empty variable is exit 4
transport_failed naming the variable. With neither key_file nor key_env
the SSH agent is used.
Windows OpenSSH hosts
os: windows nodes are read (and, by tests and Phase 23, mirrored) over the
same hardened ssh and sftp, with the behaviour recorded against a real
Windows Server 2022 / Win32-OpenSSH 8.1 host as a non-admin account
(22-LIVE-CAPTURES.md, sections C7a, C6 and C6-post):
data_diris a drive-letter path (C:\Program Files\Inductive Automation\Ignition\data; forward slashes and a lowercase drive letter are accepted). UNC and\\?\paths, relative and drive-relative paths (C:foo),.., an alternate data stream (:), a component ending in a dot or a space, and a path over 150 characters are refused with exit 2deploy_tree_invalid. sftp is always given/C:/Program Files/...: the server resolvesC:/...against the account home, so that form is never sent.- Listings. A Windows server prints long-listing names without their
directory, so each
ls -lais preceded bypwdand theRemote working directory:line marks where one directory's entries start. The client runs in a UTF-8 locale; an escaped name (caf\303\251) is refused, never read as the real one. Names pass the Windows component gate on top of the POSIX one: no:, no trailing dot or space, no reserved device name (CON,NUL,COM1...), no< > " | ? *, and no two names that differ only by case. - A listing is not proof of completeness. The sftp server drops entries
whose path is 260 characters or more, with exit 0 and no message, and
get -Rfollows junctions. Every Windows read therefore compares what sftp returned with an independent PowerShell walk of the same roots (the\\?\prefix, sizes, reparse-point flags). A missing entry, an extra entry, a size that differs, a junction or symlink, or a path of 240 characters or more is exit 4transport_failednaming the path, before any file is downloaded. A denied read of a file that exists reports "not found" on this server, so a not-found answer for a name the listing returned means unreadable. - PowerShell runs only as fixed scripts:
powershell -NoProfile -NonInteractive -EncodedCommand <base64 UTF-16LE>, parameters passed only as a base64 JSON blob decoded inside the script (never interpolated), the whole command held to 8000 characters (the host fails at 8162), a prelude that makes a failed step exit non-zero. Success needs exit 0 and exactly oneIGN-RESULT:stdout line (ASCII,\uXXXX-escaped); stderr is treated as opaque CLIXML and is decoded only to explain a failure. - Writes. A file renamed over an existing file replaces it; a rename onto
itself deletes the file on this server, so such a rename is refused for
every host. A rename answered
Failure(a scanner holding the target) is retried up to three times, then reported; nothing else is retried.
Decision B: where the ledger, the journal and staging live
Recorded at the 22-12 checkpoint (option B(i)) and applied by 22-13:
| Node | Ledger, journal and staging root |
|---|---|
ssh (Windows or Linux) | ign-deploy/<gateway>/<mode>/ relative to the service account's home directory, as the server reports it (/C:/Users/svc-ignition-deploy/ign-deploy/<gateway>/<mode>/ on Windows) |
local | <data_dir>/ign-deploy/<gateway>/<mode>/ |
The default StagingMode Phase 23 uses is Separate at
ign-deploy/<gateway>/<mode>/staging/<run_id>/ below that root
(transport::default_staging_mode): files are staged in the account home,
outside every collection, then committed into config/resources/<mode>/ by
rename (same volume, no upload under the scan lock). The reason is measured:
the service account can write its own home and nothing under the data
directory (C6), and Modify on each mode collection directory is the one
grant it needs (C6-post). A ledger is per service account, not per gateway.
Nothing writes a ledger before Phase 23, so nothing was migrated. The design
spec section 9 amendment (the account also writes ign-deploy/ in its home)
lives in whk-environment-orchestration and is still to do.
Drift (--base DIR)
--base DIR takes a directory written by ign deploy render --out (a
previous render of the same gateway, environment and mode) and marks each
entry drift: true when any of its files has a live sha256 that differs
from both the base sha256 and the desired sha256. An absent side counts as its
own value, so a unit created on the gateway outside the pipeline (absent from
the base and the desired tree, so unmanaged) is drift, and a unit deleted
outside the pipeline (in the base and the desired tree, not live, so added)
is drift. A live copy that equals the base while the desired tree moved on
(a pipeline change not yet applied) and a live copy that equals the desired
tree while the base is older are not drift. Without --base, drift,
summary.drifted and base are null. base otherwise carries {dir, render_sha256, files}.
The base is re-hashed, never trusted. render.json supplies the identity
(gateway, env and mode must equal the plan's) and a consistency record
only. Plan walks the directory, hashes every file's real bytes and refuses
the base as invalid_input, naming the first difference, when the walked
files are not exactly the marker's files, when any recomputed sha256 or size
disagrees with the marker, or when the recomputed render_sha256 disagrees
with the marker's. An extra file, an edited file, a missing file or a leftover
<file>.ign-render-tmp from an interrupted render --out refresh therefore
never passes as a clean base. An absent path, a regular file, a symlink, or a
directory holding a symlink, a special file or a non-UTF-8 name is refused the
same way, before the live tree is read. drift is excluded from
plan_sha256: the same plan with and without --base has the same hash.
The diff (--diff)
--diff adds a diff text to every added, changed and removed entry
(null for same and unmanaged). For each file of the unit whose live and
desired sha256 differ, in path order:
- a
.jsonmember where both present sides parse and carry no secret-bearing location is a unified text diff, live against desired, 3 lines of context, with headers--- live/<path>and+++ desired/<path>(an absent side is empty); - a
.jsonmember where the render's secret scan reports any refused location or warning on either side (anEmbeddedsecret object, a value at a secret-typed path, a possible secret by key name) is printed as<path>: withheld: contains secret-bearing locations (<n>); live <sha256 or ->, desired <sha256 or ->, with no content; - a
.jsonmember (any file, tag files included) where either side holds a non-empty string or a non-null scalar at or beneath an object key whose ASCII-lowercased name containspassword,passwd,secret,token,apikey,api_key,credential,privatekeyorprivate_keyis withheld the same way. This diff-only check is stricter than the render lints on purpose: the render lints were built for the desired (repo) side, but the live side is written by the gateway and never linted, so a plaintext"password": "x"in a live non-config.jsonwould otherwise print in the diff. It runs regardless of file name or OpenAPI description, adds to the(<n>)count, and keeps only the count, never the key path or the value. Render behaviour is unchanged; - any other member, and a
.jsonmember that does not parse, is printed as<path>: differs (live <sha256 or ->, desired <sha256 or ->).
An entry's diff is capped at 65536 bytes; a longer one ends with
... [diff truncated at 65536 bytes]. Diff text is excluded from
plan_sha256. No secret value, Embedded ciphertext or plaintext secret is
printed in the plan, its diff, stderr or the debug form of the result.
Over MCP. deploy plan is in the ign mcp serve tool catalog as
deploy_plan. It is read-only, so the tool is not guarded and takes no
confirm argument.
deploy pull
ign deploy pull --env <ENV> [--repo DIR] [--targets DIR] bootstraps gateways/<name>/ from
the live tree of nodes.master. It is a bootstrap, not a sync: pull once,
review the result in git, and compare later pulls with git diff. It is
read-only against the gateway: the host is read through the same reader as
deploy plan (nodes.master.local or nodes.master.ssh), no gateway session
is resolved, no REST request is sent, and nothing is written to the host.
The envelope profile is the master node's profile name, used as a label
only. It writes only the local repo, under gateways/<name>/, and never
follows or creates a symlink there.
Layering (D-12). Pull mirrors the layering render applies. Files of the
core collection go to the base tree (resources/, tags/); files of the
target's mode collection go to the modes/<ENV>/ overlay. A unit that is in
both lands in both, so render reproduces what the gateway runs. An absent
mode collection writes no modes/<ENV>/ directory. Bytes are kept exactly as
read (no newline conversion, re-encoding or JSON reformatting), and a
bootstrapped resource keeps the gateway's own attributes.uuid.
The manifest (D-10). An existing gateways/<name>/manifest.yaml is used
and kept (data.manifest is kept). Otherwise one is generated
(generated) from the live types of core and the mode collection: every
type that has a render rule, byte-sorted, minus the types that stay
gateway-owned (security singletons, security levels, API tokens, user
sources) and minus the four tag types, which are carried by tag_providers.
tag_providers lists the provider names found under ignition/tag-provider/.
Types left out are listed in data.skipped[] with the reason
type_without_rule or denied_type.
What is skipped, and why. Pull writes only units it can reproduce. It
renders its own tree and repeats, dropping units, until a round drops nothing
(at most 8 rounds), then checks that every kept unit renders to exactly its
effective live files. Every live unit not written appears in data.skipped[]
as {path, reason}, byte-sorted by path (the same unit key plan uses),
with one of:
| Reason | Meaning |
|---|---|
embedded_secret | the unit holds an Embedded secret or a plaintext secret location; nothing of it is written |
not_round_trippable | render would change the unit (for example it mints a uuid, drops a .gitkeep, or a core file is hidden by the mode copy), so the written tree would not plan as same |
render_refused: <finding> | render refuses the unit (for example a Windows-illegal name or a case-fold collision); the finding names paths and keys, never a value |
type_without_rule | a live type with no render rule (generated manifest only) |
denied_type | a live type on the deny list (generated manifest only) |
No reason carries a value, and a secret is never written to the repo, the
envelope or stderr. A skipped unit stays on the gateway: the next plan
reports it as unmanaged. After a pull, render then plan against the same
gateway reports has_changes: false: every written unit is same and every
skipped unit is unmanaged. A render defect that names no unit (for example a
manifest that allowlists a type with no render rule) is returned as
deploy_tree_invalid instead.
Overwriting (D-11, --yes). If resources/, tags/ or modes/<ENV>/
already hold files, pull refuses with exit 2 confirmation_required unless
the global --yes is given. The refusal is raised before the gateway is read
and before any write, and its message names the number of existing files and
the first five paths that would be replaced. With --yes only those three
subtrees are replaced; manifest.yaml, other environments' overlays and
targets/ are never touched. A symlink anywhere in the subtrees to be
replaced is refused (deploy_tree_invalid) and nothing is removed. A pull into
an absent or empty gateways/<name>/ (or one holding only a manifest and other
overlays) needs no --yes.
Data keys. With --json, data carries, in this order: env, gateway,
mode, node, transport (local or ssh), out (gateways/<name>),
files_written (source files under resources/, tags/ and modes/<ENV>/;
the manifest is not counted), manifest (kept or generated),
render_sha256 (what deploy render produces from the pulled tree), skipped
and warnings. Human output is pulled <n> files into gateways/<name>/ from nodes.master via <transport> (<k> skipped, manifest <kept|generated>), one
skipped <path>: <reason> line per skipped unit, then render_sha256 <hex>.
Over MCP. deploy pull is in the ign mcp serve catalog as deploy_pull
and, being guarded, advertises the synthetic optional confirm. A pull into an
absent or empty gateways/<name>/ runs without confirm, because D-11 guards
only a non-empty overwrite; the write is confined to gateways/<name>/ of the
caller's own --repo.
deploy verify
ign deploy verify --env <ENV> [--repo DIR] [--targets DIR] [--project NAME] asks whether the
master gateway runs what targets/<ENV>.yaml declares. It is
read-only: it triggers no scan, takes no scan-lock and writes nothing to
the gateway or the host. The master's profile name comes from the target and
is resolved through the normal secret chain (flag, keyring and environment, as
for any gateway verb), so the credential used is the one the target names; the
envelope profile echoes it. The host files are read through the same reader
as deploy plan (nodes.master.local or nodes.master.ssh).
The six checks run in a fixed order and always all appear in
data.checks. Each is pass, warn or fail; warn never fails the
verdict.
| Check | Passes when | Finding codes |
|---|---|---|
scan | no config scan is running | scan_active |
mode | the gateway runs the target's mode exactly (no mode is a mismatch) | mode_mismatch |
read_back | every declared resource is present over REST with an equal config | resource_missing, resource_differs (JSON paths only) |
files | every declared tag unit on the host is byte-equal to the render | files_differ |
loaded | the gateway log holds no will not be loaded line for a declared resource | unloaded_resource, logs_unavailable |
probes | every verify.tag_probes[] path of the manifest reads quality Good* | tag_probe_not_good, tag_probes_unavailable, tag_probes_absent (warn) |
The log window (CV1). loaded reads WARN-or-higher gateway log lines from
lastScanTimestamp - lastScanDuration - 1000 ms (gateway clock only; 30 s
when the gateway gives no scan duration), up to 5 pages of 200 lines, and
matches client-side. The gateway writes a line about 1.5 s after the scan
completes, so the check waits up to 5 s after the scan was read unless the
gateway has logged something since. It sees the last scan only: a clean log is
evidence about that scan, and the pass detail says so. While a scan is running
the check is warn (not observed). A log that cannot be read, or that fills
all 5 pages, is fail with logs_unavailable.
The read-back comparison (CV2). Equality is JSON value equality (key
order is ignored, 1 equals 1.0). A null member counts as absent on both
sides: nulls are dropped from the file and from the REST value before
comparing (N1). ignition/cobranding is the type whose REST view reports
its unset members as explicit null; a null on one side against a value on
the other still differs. ignition/security-levels compares by name:
every level of the file must appear in REST with its members, extra REST
levels are fine (N2). A unit with no config.json compares only the
attribute keys REST also carries (N3). A REST item whose effective collection
is system, and the types that have no REST read route
(com.inductiveautomation.opcua/one-time, ignition/images), are listed in
data.warnings and are not counted in data.resources_checked. A
hand-written, minimal config.json can differ from REST in members the
gateway fills with defaults; a tree made by deploy pull carries the full
config and compares equal.
Probes. The manifest's verify.tag_probes are read through the WebDev
tags route of --project, every second for up to 30 s, and the last reading
is judged. No probes declared is a warn (tag_probes_absent), never a
pass. A route that cannot be read (for example routes_not_deployed) is a
fail carrying only the error slug.
What is never printed. A finding carries a code, a subject (a resource
key, unit key, tag path, mode or logs) and a detail made of JSON paths,
quality codes, timestamps, logger names and error slugs. Log message text, tag
values and file bytes never reach stdout, stderr or the envelope.
Output. Human output is verify <gateway> (<env>, mode <mode>): PASS|FAIL,
one <id>: <status> <detail> line per check, and one indented
<code> <subject> <detail> line per finding. With --json, data carries, in
this order: env, gateway, mode, node (master), project, ok,
slug, checks, resources_checked and warnings. slug is
deploy_verify_failed when ok is false, else null.
Checking the backup (--node). --node master|backup|both picks which
node of a redundant pair is checked; the default master prints exactly what
deploy verify printed before the pair existed (no backup and no secrets
key). --node backup runs only the backup checks, --node both runs the six
master checks and then the backup checks. The backup checks are the same four
that deploy apply reports for the backup (data.backup):
redundancy (the backup is linked to the master and has no sync pending),
mode (it runs the target's mode), read_back (the rendered resources over
its REST view) and files (its files against the master's live copy of
every rendered path, compared byte for byte except resource.json, which is
compared as a JSON document that leaves out only attributes.lastModification
and attributes.lastModificationSignature (a node that re-saves a unit rewrites
them); a backup with no ssh or local transport in
nodes.backup reports files as warn). Verify never waits, never sends a
resync and never writes: it takes one look, so a backup that is still catching
up reports not_syncing and the command exits 6; run it again later. In
data.backup the budget_s and waited_s are 0 and resync is null, and
with --node backup the envelope profile is the backup's and data.checks
is empty.
The secret-name check (data.secrets). With --node backup|both the
secret names the rendered tree references (Referenced secrets, by provider
and name) must exist on each checked node. data.secrets.nodes[] has one entry
per node, master first, with node, status, providers_checked, missing
([{provider, name}], byte-sorted) and unverifiable (providers that cannot
list their names). Names compare exactly and case-sensitively. A name missing
on any checked node is fail and turns the verdict red. A provider the render
declares but a node does not have counts as missing. A provider that cannot
list its names (the gateway answers 501, or 500 for a remote provider) is
warn, is named in unverifiable and does not fail the verdict. Any other
refusal of the listing is an ordinary error envelope. The check lists names
only: no secret value is read, printed or stored. Human output adds
secrets on <node>: <status> (<n> providers) with missing: <provider>/<name>
and unverifiable: <provider> lines.
--node backup and --node both against a target without nodes.backup are
invalid_input (exit 2), raised before any gateway or host is contacted.
Exit codes. A green verdict exits 0. A red verdict exits 6 after the success envelope is printed; see the verdict exception. A gateway that cannot be reached, a refused scan, mode or resource read, an unreadable host and a refused tree are ordinary error envelopes with their own exit codes: there is no evidence to judge, so no verdict is given.
Over MCP. deploy verify is in the ign mcp serve catalog as
deploy_verify. It is read-only, so the tool is not guarded and takes no
confirm. A red verdict is a normal result (ok: true, data.ok: false),
not an MCP error.
deploy apply
ign deploy apply --env <ENV> [--project NAME] [--repo DIR] [--targets DIR] [--base DIR] [--plan-sha256 HASH] [--allow-destructive] [--allow-drift REASON] [--designers fail|wait|proceed] [--designers-timeout SECS] [--skip-gwbk] [--backup-timeout SECS] writes
what targets/<ENV>.yaml declares into the master node's mode collection,
then verifies it and, for a redundant pair, checks the backup. The Commands row has the flag summary. The verb is
guarded (it needs the global --yes; the refusal message is the plan) and
is not in the MCP catalog. It has no --force, --skip-verify or
--no-rollback: an apply ends verified, or with its pre-apply snapshot
restored and scanned.
Order. Each step refuses before the next one starts, and the refusals up to the confirmation write nothing to the host, the gateway or the repo:
- The flags are validated, and Decision C is checked (below).
- A journal left by an interrupted run is refused (
deploy_journal_present, exit 6; the message prints the exactign deploy rollback --run <id> --env <env>recovery command). - The plan is rebuilt from the repo and the live tree. A
--plan-sha256that is not this plan's hash isplan_stale(exit 6), checked before anything else about the plan. - An empty plan is outcome
noop(exit 0). It sends no lock, backup, scan or restart request and creates no.ign/directory. - An entry that a core copy shadows (
shadows_removal) isdeploy_blocked(exit 6) whatever the flags. - A destructive entry (a removal, or a change that deletes files) without
--allow-destructive, and an entry with drift (--base, see Drift) without--allow-drift REASON, aredestructive_change_refused(exit 2). The reason is kept inrun.jsonexactly as validated: not empty, at most 500 characters, no control characters (otherwiseinvalid_input). - Without
--yes,confirmation_required(exit 2) prints the plan: env, gateway, mode, each changing entry with its flags, the delete-set count andplan_sha256. - The run directory is created (below), the preflights run, the
.gwbkis downloaded, the files are staged on the host, then the lock is taken, the snapshot copied, the files moved, the scan run and the result verified with the same six checks asdeploy verify. A pre-image that no longer matches the plan under the lock aborts with nothing moved asplan_stale.
Decision C. --plan-sha256 is required when --json is given or when stdin
or stdout is not a terminal; a missing hash is invalid_input (exit 2) before
any host or gateway contact. Interactively it is optional, and the refusal
without --yes shows the hash to pass. To review and apply unattended, run
deploy plan --json, read plan_sha256, and pass it back: a plan that changed
since is plan_stale.
Preflights. Ten checks run in a fixed order and are reported together
as one deploy_preflight_failed (exit 6), so one run shows every problem; the
detail strings name checks, counts and sources, never values.
| Check | Fails when |
|---|---|
mode_master | the master runs a different deploymentMode than the target's mode |
mode_backup | the target names a backup node and it runs a different mode (skipped with no backup node) |
wrong_target | the gateway's REST name is not the host's systemName (or not nodes.master.gateway_name when set) |
write_rest | the gateway refuses the project scan with a 403 |
write_host | the host account cannot write its staging and snapshot directories |
designers | a Designer session is open (per --designers, below) |
hold_budget | the change does not fit the lock hold (the size limit is in the Commands row) |
master_active | the master is not the only node reporting activityLevel Active: the pair switched over (the backup is active), both nodes are active (split-brain) or neither is. Decided by activityLevel, never by role, so a healthy Cold standby passes. skipped without a backup node, and a backup node with no gateway connection cannot be confirmed and fails |
backup_read | the backup's mode collection cannot be read through the read-only access in nodes.backup (the detail carries the error code). skipped for a backup with only a gateway connection (the detail says the file-hash check did not run), without a backup node, and in a rollback |
secrets | a secret name the rendered tree references is missing, by exact case-sensitive name, from a secret provider on the master or on the backup. Only names are listed, never values. A provider the tree itself declares that answers 404 is skipped and named in the detail. A name the tree itself adds to a provider it declares (an entry in settings.files or settings.secrets of that provider's own config.json) is defined by the deploy, so it is not asked of a node and never refused, even when the provider already exists there; only names the tree references and does not provide are checked. A provider that answers 500 or 501 cannot list its names, is named unverifiable and is not a failure. skipped when the tree references no secret, and in a rollback |
Two more refusals use the same slug and are raised on their own: gwbk (the
backup download) and run_dir_not_ignored (below). A gateway or host that cannot
be reached is not a finding; it is network_error or transport_failed (exit
4).
The backup (.gwbk). After the preflights and before the lock, so it never
spends lock time, apply downloads a gateway backup (type=all) into the run
directory as <run id>.gwbk, created at 0600. It must open as a ZIP with at
least one entry; an empty, truncated or failed download is deploy_preflight_failed
with check gwbk, and the file is deleted. --skip-gwbk bypasses it and is
recorded in run.json and result.json (skip_gwbk: true). The backup is a
whole-gateway secret archive.
The backup check. When targets/<ENV>.yaml declares nodes.backup, apply
ends with proof that the backup node holds what the master now holds, and
never writes to the backup: it reads the backup's REST API and, only when
nodes.backup declares ssh or local, its mode collection files through the
read-only file access. Before the first write it samples the backup's
lastSyncTimestamp (an unreadable sample refuses with its own error before any
write). After the master is verified and the lock released it waits outside the
lock for up to --backup-timeout SECS (default 300, 1 to 86400), polling the
backup every 5 s, until the pair link is up, no sync is pending, the
timestamp is later than the sample, and, where files can be read, every
rendered and deleted path matches the master's and a REST read-back of the
rendered resources agrees. If that does not happen, apply sends one resync
request to the master (never to the backup) and waits once more. The result is
data.backup (converged, reason, budget_s, waited_s, resync
{ok, code} or null, last_sync_before, last_sync_after, project_state,
files_checked and checks with ids redundancy, mode, read_back and
files, each pass, fail or warn, with findings that carry no values). A
backup that does not converge ends applied_backup_unsynced with exit 6; see
the backup-unsynced verdict exception.
A backup reachable only by REST has no file access, so the file-hash check
cannot run: its files check reads warn with the sentence the file-hash check did not run, in human output and in --json, and convergence rests on
the REST checks. Human output adds backup: converged or backup: NOT converged (<reason>), the seconds waited, whether the resync was sent and its
result, and one line per check; progress goes to stderr, and under --json
stderr stays empty.
Designers. --designers fail (the default) refuses while any Designer
session is open. wait polls every 10 s for up to 600 s, or --designers-timeout SECS (1 to 86400), and on timeout refuses with the same session list. proceed
takes no reason string; run.json and events.ndjson record that it was used and
the sessions open at the time (user, project, host).
The run directory. Every run (after the gates and the confirmation) gets
<repo>/.ign/deploy/<gateway>/<run-id>/, created at 0700 with files at 0600
(an owner-only ACL on Windows). The run id is a UTC timestamp and a random suffix,
for example 20261015T142233Z-a1b2c3. It holds run.json, result.json,
plan.json, events.ndjson, the downloaded snapshot (snapshot/) and the
.gwbk. Add .ign/ to the repo's .gitignore: the directory holds the
pre-apply files and the backup, so apply refuses before any write when git would
commit it (deploy_preflight_failed, check run_dir_not_ignored; a directory
that is not a git work tree proceeds and says so in run.json).
Where secrets can be, and where they cannot. The run directory holds secrets
in exactly two places: snapshot/ (copies of the gateway files the run replaced
or deleted) and the .gwbk. That is why the directory must be git-ignored and
owner-only. Nothing else a run writes or prints carries a secret value: not
run.json, result.json, plan.json or events.ndjson, not stdout, stderr or
the --json envelope, not the on-box journal.json and record.json, and not
the confirmation text, which names the run, gateway, mode, counts, plan_sha256
and changed paths only. Tests plant a canary value in a secret field of the live
resources apply replaces, in the .gwbk the gateway serves, in gateway log lines
and in an error body, then run apply (applied, no-op, rolled back, refused) and
rollback (restored, refused, recovered) and assert it appears in no file or
stream except those two named, private, git-ignored artifacts. Free text you type
is recorded once: an --allow-drift reason is written to run.json as given and
is not echoed anywhere else, so do not put a secret in it.
Runs are never pruned. Nothing deletes a run directory or an on-box
snapshot, in this release. To remove a run by hand, delete its run directory and
the host's snapshots/<run-id>/ directory under the
ledger root
(ign-deploy/<gateway>/<mode>/). A run whose snapshot is removed can no longer
be rolled back. Never delete a journal.json by hand unless the
rollback_failed guidance says to.
Restarts are reported, never performed. Apply reads the gateway's pending
restart list before and after the run and never restarts anything:
data.restart_pending is the list after the run and data.restart_pending_new
the entries that were not pending before it (both [] when none). A list that
cannot be read is the warning restart_pending_unavailable, never a failure.
snapshot_copy_differs (<path>) warns when the run directory's copy of a
pre-image does not match the host's.
Windows hosts. The deploy account needs Modify on the mode collection
and ownership of the files it moves (apply resets each moved file's ACL, which
needs the owner's WRITE_DAC). The sftp server cannot read a path of 260
characters or more, so a collection path whose copy below the account home would
pass 259 characters is refused by write_host, with the path named. A file
another process holds open cannot be read or moved: apply stops before the first
move as transport_failed naming the path, and nothing changed.
Linux hosts. The deploy account needs read and execute (list) on data and
data/config (not recursive, no write), read on config/resources, read,
write and execute on each mode collection directory (inherited by new entries),
and a writable home for ign-deploy/. Without the
list right on data, deploy plan stops with transport_failed
(remote readdir(".../data/"): Permission denied) because the read half lists
every ancestor to refuse a symlink on the path.
Project deploys (deploy projects). The .git probe of
deploy projects needs only the list right (read and
execute on Linux, ReadAndExecute on Windows) on data/projects and on each
project directory it asks about, and no write right anywhere under
data/projects: the deploy account's write right stays on the mode collection.
As the capture hosts are provisioned no grant is needed (a project directory
the gateway creates is 755 on Linux and inherits Users read-and-execute on
Windows). A host that keeps its project directories private needs one:
- Linux:
setfacl -m u:<deploy-account>:r-x <data>/projects/<project>for an existing project directory, and, so that project directories created later inherit the right, a default ACL on the parent:setfacl -d -m u:<deploy-account>:r-x <data>/projects. - Windows:
icacls "<data>\projects\<project>" /grant "<COMPUTERNAME>\<deploy-account>:(OI)(CI)(RX)"for one project directory, or the same ACE on<data>\projectsfor all of them.
Without the right the probe cannot tell a missing .git from one it may not
see, so it says so: transport_failed naming the path (Permission denied on
Linux, UnauthorizedAccessException on Windows), never "no git". A project
directory that does not exist is the create case and needs no right. On ssh
nodes the release record lives in the account home,
ign-deploy/<gateway>/projects/<project>/release.json (the same home as the
ledger); on a local node it is <data_dir>/ign-deploy/<gateway>/projects/<project>/release.json. The record is the one file deploy projects writes on a gateway host: on an ssh node it is written only in the deploy account's home (ign-deploy/<gateway>/projects/<project>/release.json), as a temporary file beside it that then replaces the record in one atomic step (mv -f on a Linux host, a Win32 MoveFileEx replace on a Windows one), so a reader sees the old record or the new one on NTFS (Windows) and with Linux rename(2), never a missing or half-written file, and no write right on the gateway's data directory is needed for it. The replace is atomic for readers, not a durable write. A reader that holds the record open without delete sharing makes the Windows replace retry (Win32 error 32 or 5, RENAME_RETRIES times, a second apart) and then fail with transport_failed, keeping the previous record and removing the temporary file; after a verified import that is record_written: false plus a warning. The Windows record write needs three things from the deploy account: a FullLanguage PowerShell session, the .NET Framework C# compiler csc.exe that Add-Type runs, and a writable %TEMP%. Without any of them every record write fails closed with transport_failed naming the cause. Each record write compiles once in a fresh PowerShell process (the whole replace took 2.70 s on the capture host).
The commit has a deadline. The scan-lock is held for 176 s and the commit
must be done inside it. The work list carries a time budget (the hold less a 2 s
margin, 174 s), counted from the start of the commit script: the script starts
no move, delete or retry after it, stops, and says so (deadline). The apply
then ends as a restore with the cause lock_expired, under the lock if it is
still held. The ssh call itself is killed 60 s after the budget instead of after
ssh.op_timeout_s; if that backstop fires, the run is the interrupted run
below (deploy_journal_present, recover with ign deploy rollback --run <id> --env <env>).
A failure after the verify. If the verify is green and the fingerprint or
the final record step then fails, the deploy is live but not recorded: the run
ends as deploy_journal_present with the recovery command, the files are left as
written and nothing is restored on a guess. rollback --run restores the
pre-apply files (the post-verify window below). When the answer was lost but the
host had finished (the journal is gone), the apply is applied with the warning
finalize_reported_error (<code>). A commit error when the host cannot be read
back is the original error with the same recovery command appended.
After an ssh drop. A recovery started right after a dropped ssh connection should wait out the dead apply's lock hold, because its remote script may still be running until the hold lapses.
Output. Human output is apply <gateway> (<env>, mode <mode>) via <transport>: <outcome>, then the run id and directory, the counts written and
deleted, for a rollback the cause, the backup name or its skip, any restart
lines and plan_sha256; warnings go to stderr. With --json, data carries the
keys listed in the Commands row.
Exit codes. 0 applied or noop. 2 confirmation_required, invalid_input and
destructive_change_refused. 3, 4 and 5 for configuration, network (including
transport_failed) and authentication, as for every gateway verb. 6 for
plan_stale, deploy_blocked, deploy_preflight_failed, deploy_journal_present,
scan_lock_held, rollback_failed, for a rolled-back apply, which prints
its envelope first, see
the rolled-back verdict exception,
and for an apply whose backup did not converge (applied_backup_unsynced), see
the backup-unsynced verdict exception.
deploy rollback
ign deploy rollback --env <ENV> --run <ID> [--repo DIR] [--targets DIR] [--discard-live-changes] [--designers fail|wait|proceed] [--designers-timeout SECS] puts a past run back:
it restores the pre-run files from the run's on-box snapshot into the master
node's mode collection, deletes the files that run created, scans, and records
the rollback. It also recovers an interrupted run (below). The Commands row
has the flag summary. The verb is guarded (it needs the global --yes; the
refusal message names what it would do) and is not in the MCP catalog.
From a fresh process. --run is the run_id that deploy apply printed, or
the name of its run directory. Only targets/<ENV>.yaml is read: the declared
tree is not rendered, and no run directory has to exist (the on-box record is
the source of truth; a local run directory that does exist is cross-checked
against it). A malformed --run is invalid_input (exit 2) before the target
file, any host or any gateway is touched.
Order. Each step refuses before the next one starts, and the refusals up to the confirmation write nothing:
- A journal of another run is
deploy_journal_present(exit 6) and prints the recovery command for that run (below). - The run's on-box record is read. A record that is missing or damaged, for
another gateway or mode, still
applyingwith no journal, or already rolled back isdeploy_run_invalid(exit 6), naming the run; a run is rolled back once. - Files the run touched that changed after it (a later apply, a Designer
save) are
deploy_live_changed(exit 6), naming the first ten paths and the count, unless--discard-live-changesis given. With the flag they are overwritten, and listed in the rollback'srun.json, itsevents.ndjsonandresult.json(discarded_live_changes,[]when none). Files the run never touched are left alone and never block. Rolling back an older run while a newer run changed the same file therefore refuses; roll back the newer run first, or discard. The check is made again under the scan-lock, just before the restore (the preflights can wait for Designer sessions), so a save that lands in between is refused the same way (the lock is released by one no-op scan), or listed when the flag is given. - Without
--yes,confirmation_required(exit 2) prints the run, the gateway, the mode, how many files it would restore and delete, theplan_sha256of the run, and any live change it would overwrite. - The rollback gets a run directory of its own (
kind: rollback, same layout and.ign/ignore rule asdeploy apply), the same preflights and--designerspolicy as an apply (rollback runsmaster_active, so it refuses when the master is no longer the active node;backup_readandsecretsare skipped, because a rollback renders no tree) (thehold_budgetcheck is sized by the files the run wrote and deleted, counted as the apply counted them, so a change the apply accepted can be rolled back), the same one 176 s lock hold, then the restore, the config scan and the on-box record is markedrolled_backwith the rolling-back run's id. No.gwbkis taken: the restore point is the on-box snapshot.
A rollback removes the files the run created and the directories that emptied
below <module>/<type>. The <module>/<type> directory itself and the module
directory above it are never removed, so an empty one can remain after rolling
back a run that created the first resource of a type; the live rollback (23-16)
left such directories and deploy plan and the REST view were as before.
Runs and snapshots are never pruned, and a run whose snapshot was removed can no
longer be rolled back: see "Runs are never pruned" under
deploy apply, which also says when journal.json may be
deleted by hand. The restart-pending lists are reported, never acted on, as for
an apply.
Recovering an interrupted run (DAPL-09). An apply that was killed after it
began leaves journal.json on the host. While it is there, deploy plan,
deploy apply and deploy rollback --run <another run> all refuse with
deploy_journal_present, and the message ends with the exact command that
recovers it:
ign deploy rollback --run <id> --env <env>
That command is the whole recovery; no flag is needed and none discards a
journal (D-09, D-12). It skips the live-change check, because a killed run has
no post-run state to compare against (D-11), and records the files' hashes as it
found them in the new run's interrupted_run event. What it does depends on the
journal:
- A run that died before any file moved, whose touched files are still what
they were before the run, is cleared: the journal, the partial snapshot and
the staging are removed, nothing is restored and nothing is scanned
(
data.outcomecleared, D-10). - Anything else is restored from the snapshot exactly as a rollback is
(
restored). That includes a journal whose files a foreign edit changed before the first move: the snapshot overwrites that edit. - The post-verify window (A9): an apply killed after a green verify and before it recorded itself leaves a journal for a good deploy. Recovery cannot tell the difference and restores the pre-apply files. Re-run the apply afterwards.
- Recovery reads the snapshot and checks it against the journal before it
restores anything. A lost or corrupt snapshot is
rollback_failed(exit 6) with the journal left in place. Anyrollback_failedleaves the journal and the on-box snapshotsnapshots/<run-id>/(beside the journal) as they were, so first retryign deploy rollback --run <id> --env <env>, which the message prints. Only when the retry reports a lost or damaged snapshot, restore the files by hand from the run's.gwbk(when it took one) or from that on-box snapshot, then delete the journal at the path the message prints. That message is the only case in which the journal is deleted by hand (D-12). The run directory'ssnapshot/copy is made only after a restore has succeeded, so it is not a source here.
A recovery started right after a dropped ssh connection should wait out the 176 s
hold first (see "After an ssh drop" under deploy apply).
Seen live (23-16): a run killed during the write still holds its scan-lock, and a
recovery started at once is refused scan_lock_held (exit 6, naming the dead
run's token) until that hold lapses; a run killed after the scan, in the A9
window, has already released the lock and recovers at once.
There is no Ctrl-C handler in v1.6 (A10): an interrupt leaves the lock to expire
with its hold and, once the journal exists, a journal to recover as above.
Hosts. The local host is covered by the CLI tests, and the Windows ssh
host's script was run on a live Windows host; the Windows notes under
deploy apply apply to the restore too. Linux ssh was run
against a live host (FA-23-05, OQ8, 2026-10-19): an Ubuntu 22.04 host with
OpenSSH 8.9p1 and dash as /bin/sh, reached as a key-only account with no
sudo. There commit.sh and its restore ran over ssh (prepare, commit, the
stub's tamper refusal, restore), the commit left 0644 files and 0755
directories under a session umask of 0002, a commit stopped at its time
budget, the shared host conformance table passed 48 of 48, and ign deploy plan, apply, verify and rollback --run against the real production
collection restored it exactly. The account needs read and execute (list) on
data and data/config as well as write on the mode collection: the read half
lists every ancestor from the data directory to refuse a symlink on the path.
Not captured: the move of staged files when the account home and the gateway
data directory are on different file systems (a copy and delete, not an atomic
rename). Run a first Linux ssh apply or rollback against a disposable gateway,
and keep the .gwbk.
Output. Human output is rollback <gateway> (<env>, mode <mode>): <outcome>,
then rolled back run <id>, run <id>, run directory <dir>, the counts
restored and deleted, any discarded live changes and the restart lines; progress
lines are prefixed ign deploy rollback: on stderr, in human mode only. With
--json, data carries the keys listed in the Commands row; data.ok is
always true because a rollback that cannot restore is an error envelope.
Exit codes. 0 restored or cleared. 2 confirmation_required and
invalid_input. 3, 4 and 5 for configuration, network (including
transport_failed) and authentication. 6 for deploy_journal_present,
deploy_run_invalid, deploy_live_changed, deploy_preflight_failed,
scan_lock_held and rollback_failed. A rollback has no verdict exit: unlike
deploy apply, it never ends in a success envelope with a non-zero code, so
the rolled-back verdict exception
does not apply to it.
deploy projects
ign deploy projects --env <ENV> --release <TAG> [--allow-downgrade] [--allow-first-change REASON] [--check | --artifact-sha256 HASH --yes] [--designers fail|wait|proceed] [--designers-timeout SECS] [--repo DIR] [--targets DIR] deploys one Ignition project from its own
repo, and with --check reports what that would do. The Commands row has
the flag summary. --check sends GET requests only, creates no .ign/
directory and writes nothing on the gateway or the host. Without --check the
verb is a guarded write (Deploying). The verb is
not in the MCP catalog, --check included.
What it deploys. One project per repo. The repo is the project's own
checkout (a root project.json beside the ignition/ directory and the module
directories), not a gateway-configuration repo. The artifact is built from the
checkout at run time (D-01), and nothing needs to be committed as a pin:
Manifest.projects is not read by this verb (D-04). A project's parent
comes from the repo's own project.json. The verb never imports, exports or
modifies the parent, and never touches any other project on the gateway
(D-05).
deploy/project.yaml names the gateway project and the environments it
may deploy to (D-08). The file is strict: exactly these three keys, no others.
schema_version: 1
project: CapApp
environments: [dev, prod]
schema_versionmust be1.projectis the project's name on the gateway. It must be one path component that is safe on POSIX and Windows hosts, because it becomes a directory name on the gateway host.environmentslists every--envthe project may be deployed with. An--envthat is not listed refusesdeploy_tree_invalid(exit 2) namingdeploy/project.yaml, before any request.
What goes into the artifact (D-09, K1 to K4). The gateway keeps only what it adopts on an overwrite import and drops the rest without saying so, so the artifact holds exactly that and nothing more:
- The layout is the checkout layout, verbatim. A member's key is its path in
the zip; no
resourcessegment is stripped by name (acom.inductiveautomation.webdev/resourcesdirectory is real). - The root
project.json, plus the top-level directories namedignitionor shaped like a module id (com.inductiveautomation.perspective). Every other root entry (tags/,docs/,deploy/,.github/, and so on) stays out. - Below those, each directory holding a
resource.jsoncontributes that file and exactly the files itsfilesarray lists (K2). Two refusals, bothdeploy_tree_invalidnaming the path, because the gateway would drop the whole resource: a listed file that is missing from the folder, and a directory inside a resource folder. Unlisted files, dotfiles and folders without aresource.jsonare left out. .git,.github,.ign,node_modules,__pycache__and*.pycare never read, at any depth, so a previous run's.ign/directory never enters the artifact.deployis left out at the repo root only, because the root allowlist already does that; a script package or view nameddeploybelow a module directory is kept. A symlink or special file in the walk refusesdeploy_tree_invalid; it is never followed.- An optional
.ignexcludeat the repo root removes more paths. One repo-relative path per line; a trailing/names a directory and its whole subtree;*matches within one path segment and never crosses a/; a line starting with#is a comment; blank lines are ignored; spaces and tabs around a line are trimmed; CRLF line endings are tolerated. There is no negation. A line using!,**, an absolute path, a.or..segment, a backslash, a control character or a non-printing format character (such as a byte order mark) refusesdeploy_tree_invalidnaming.ignexcludeand the line number..ignexcludecannot removeproject.json, and a.ignexcludeline that removes a file aresource.jsonlists refuses rather than shipping a resource the gateway would drop..ignexcludeitself must be a regular file, not a symlink. - Everything left out is reported in
data.excluded(byte-sorted, at most 200 paths,excluded_countbeside it; a directory appears once with a trailing/), and the human output prints the count and the first 20, with control characters in a name written out as escapes. Read it in the check: it is the list of what stays out of the uploaded project. - Limits, all refused as
deploy_tree_invalidbefore any gateway contact: a walk past 200,000 entries, a built zip over the 512 MB import limit, a file name below a module directory that is not valid UTF-8, and a checkout whose artifact holds nothing besidesproject.json(an import would empty the project). The built zip is also checked likeprojects importchecks a file (ZIP magic, size, every entry readable to its end) before it is reported.
Mark the deploy files -text in .gitattributes:
deploy/** -text
deploy/project.yaml refuses CR line endings, while .ignexclude tolerates
them, so a Windows checkout with core.autocrlf would break the first and not
the second.
The artifact_sha256 and live_sha256 digests are canonical: they hash each
member under its zip path and ignore zip entry order, directory entries and the
fields the gateway rewrites (attributes.lastModification and
lastModificationSignature in each resource.json, the order of its files
array, and the description and parent keys it adds to project.json). An
export of an unchanged project therefore compares equal to the artifact, and a
project that holds no members is refused rather than compared.
--targets is a root directory (D-07, D-14). targets/<ENV>.yaml is read
below it, and it defaults to --repo. A target file that is not found under
the root refuses deploy_tree_invalid naming targets/<ENV>.yaml, so a repo
that keeps its targets elsewhere passes the directory that holds targets/.
The same root resolves the known_hosts path of an ssh node (D-07): a target
block's known_hosts: hosts/lnx.known_hosts is read from
<DIR>/hosts/lnx.known_hosts, and a path that leaves the root (.., an
absolute path) is refused when the target is read.
The check output. JSON data keys, in order: env, gateway, project,
release, check, action, would_change, artifact_sha256, live_sha256,
members, removed, removed_count, excluded, excluded_count,
unmanaged, unmanaged_count, parent, previous_release, recorded_sha256,
live_matches_last_deploy, allow_downgrade, allow_first_change,
warnings, run_id, run_dir, record_written, restore_command,
outcome, ok, slug.
actioniscreate(the project is absent from the gateway),skip(the live project equals the artifact and the release record already names this release),record(it equals the artifact but the record is absent or names another release, so only the record would be written) orimport(it differs).would_changeis false only forskip.live_sha256isnullwhen the project is absent.memberscountsadded,changed,removedandsamemembers against the live project.removedlists the live members an overwrite would delete, which the gateway never reports itself (it answers 200 andsuccess: true); it is the only place those deletions show.excludedlists the repo paths left out of the artifact (see above).unmanagedlists the names of the other projects on the gateway, and nothing else about them. Lists are byte-sorted and capped at 200 entries with the full count beside them (removed_count,unmanaged_count).parentis the parent named in the repo'sproject.json, ornullfor a root project.outcomeischecked,okistrue,slugisnull,run_idandrun_dirarenull,record_writtenisfalseandrestore_commandisnullin a check.
Release order and the first deploy. --release <TAG> is a semver tag with
at most one leading v (v1.4.0, 1.4.0, v1.5.0-rc.1). It is parsed before
anything is read from the host or the gateway, and a tag that is not semver
refuses invalid_input (exit 2). Releases are ordered by semver precedence, so
v1.2.0-rc.1 is older than v1.2.0, and build metadata (+build) is ignored.
The tag is taken as given; it is not checked against the repo's tags. The host
keeps a release record for each project at
ign-deploy/<gateway>/projects/<project>/release.json, below the same root the
other deploy records use on that node (the deploy account's home for an ssh
node, the data directory for a local node); it names the release this tool last deployed, the canonical
digest of what it deployed and the run that did it. The check reads the record
and never writes it, and creates nothing on the host. A record that cannot be
trusted (not JSON, an unknown field, a schema_version other than 1, over
64 KiB, a gateway or project that is not this deploy's, a digest that is not
64 lowercase hex characters, a release or run id that is not valid) refuses
deploy_run_invalid (exit 6) naming the file; it is never read as absent. A
mode collection named projects is refused in a target file, so it can never
share that directory.
- Downgrade. When the record names a newer release than
--release, the check refusesdestructive_change_refused(exit 2) with gatedowngrade, naming the project, before any gateway request, and so will the apply. An equal release is allowed.--allow-downgradelets the older release through; it is reported indata.allow_downgrade.data.previous_releaseis the release the record names, ornull. - First deploy of a project the git module left (D-12). With no record, a
project that exists on the gateway and differs from the artifact refuses
destructive_change_refusedwith gatefirst_deploy, because the first deploy by this tool would change what the gateway runs. Release what the gateway runs first, or give a reason:--allow-first-change <REASON>, 1 to 500 characters, reported indata.allow_first_change. A project absent from the gateway iscreateand needs no reason. - Record only (K14). When the live project already equals the artifact the
project is never touched. With no record, or a record that names another
release, the action is
recordandwould_changeis true: a deploy will write only the release record, which adopts a git-module project without an import. When the record names this release, the action isskipand nothing is written. - Drift (K13).
data.recorded_sha256is the digest the record holds, anddata.live_matches_last_deploysays whether the live project still equals it (nullwithout a record or without a live project). A live project that no longer matches is a warning, "the live project no longer matches the last release this tool deployed", and does not refuse: someone changed the project on the gateway since the last deploy, and an import would replace that.
The check applies both refusals with the flags a deploy will take, so a CI check job fails before a reviewer is asked to approve.
A project absent from the gateway is created (D-13). The check reports it
as create, with live_sha256: null and every artifact member counted as
added. It is not a refusal. A deploy creates the project (overwrite=false,
no pre-image because there is nothing to save), with the same confirmation and
artifact hash as an import, and the outcome is created.
Git-managed projects. Before it asks the gateway anything, the check
looks for data/projects/<name>/.git on the master node's host (the node must
declare ssh or local; a node with neither refuses deploy_tree_invalid
before any gateway contact). Any .git entry counts: a directory, a file (a
worktree or submodule pointer) or a symlink, and a symlink is never followed.
A project that has one refuses project_git_managed (exit 6), naming the
project, the location and the kind. The probe fails closed: a projects
directory that does not exist or a project directory that cannot be read is
transport_failed (exit 4), never "no git". An absent project directory is
the create case and reads as no .git. The verb never deletes or renames a
.git, and offers no flag that does. Cutover is per project: an administrator
removes that project's .git on the gateway, then runs the check again (D-11;
the runbook is Cutting a project over from the git
module).
Parents. A child project's parent is read from the repo's own
project.json (D-05), and the check looks it up with reads only: one
projects/list call carries the parent, its inheritable flag and its own
parent for every project, so nothing is asked by name. A parent the gateway
does not list refuses project_parent_invalid with reason missing
(exit 6); a project that names itself, or whose parent chain leads back to it
or never ends, refuses with cycle (the walk takes at most as many steps as
the gateway lists projects, plus one); a parent whose inheritable is false
refuses with not_inheritable. All three refuse before the project is
exported, and the verb never imports, exports, modifies or reparents the
parent or any other project. Order is the CI's job: deploy the parent from its
own repo first, then the child. A project without a parent in project.json
is not checked, and the human output prints parent: none. The gateway's
importer refuses none of these (it answers 200 and flags invalidParent
afterwards), which is why the check runs first. When the parent is usable but
the live project sits under a different parent, the check warns that an import
changes it from one to the other; that is a warning, not a refusal. A usable
parent that the gateway itself flags invalidParent (a problem further up its
own chain) is a warning too.
Deploying. The same command without --check deploys. It runs the same
refusals as the check first (.git, parent, downgrade, first deploy), then:
- The artifact hash (D-01).
--artifact-sha256 HASHis theartifact_sha256that--check --jsonreported for this checkout, 64 lowercase hex characters. It is compared with the artifact the checkout builds after the build and before any host or gateway request, so a different artifact refusesproject_hash_mismatch(exit 6, the project and the two hashes only) with nothing contacted or uploaded. Without--check,--json,--compact, or a stdin or stdout that is not a terminal needs the hash (invalid_input, exit 2, before any contact); a value that is not 64 lowercase hex isinvalid_inputtoo. - Confirmation. Without
--yesthe plan is the refusal (confirmation_required, exit 2): the action, project, gateway, environment, release, member counts, the paths an overwrite deletes (the gateway never reports them) and the hash. A project that is already this release (skip) is returned before that, and writes nothing, not even a.ign/directory. - A run directory.
<repo>/.ign/deploy/<gateway>/<run-id>/(mode 0700, files 0600, created only after git is shown to ignore.ign/) holdsrun.json,plan.json,events.ndjsonand, on every end,result.json.run.jsonrecords the release, the artifact hash and the override flags. - The pre-image. For an
importthe live project is exported intoexport/<project>.zip(0600) before anything is sent. If its canonical digest is not the one the plan was drawn against, the run refusesplan_stale("live project changed since the plan", exit 6) and imports nothing. The export holds the live project's files and is never printed or copied into any other file; keep the directory private. - The import goes to the master node's gateway only (
nodes.backupis never contacted: the redundancy sync carries the project to it). An existing project is replaced (overwrite=true); an absent one is created (overwrite=false). - The verify. The project is exported again and its canonical digest is
compared with the artifact's. Equal is outcome
imported(orcreated), and the release record is written on the master node's host, to a temporary file that is then renamed overrelease.jsonin one atomic step (mv -fon Linux, a Win32MoveFileExreplace on Windows; on NTFS a replace still blocked by a reader after its retries keeps the previous record, and the run ends withrecord_written: falseand a warning). A gateway answer that lists no change adds a warning and the comparison decides. When the project has a parent, the gateway'sinvalidParentflag is read after the import and a true value is a warning. - A record-only run (
record). When the live project already equals the artifact, nothing is imported: the live project is read once more to be sure it still equals the artifact, and only the record is written (outcomerecorded).
If the digest read back is not the artifact's, or the read-back fails or
cannot be compared, the outcome is imported_unverified and ok is false:
no record is written, no second import is sent and no further gateway request
is made. The saved pre-image is in the run directory. An import request that
fails after it may have reached the gateway (a timeout or a reset, or an
unclassified gateway answer) is read back the same way, with a warning naming
the error code: an equal read-back is imported, anything else is this
verdict. A refusal the gateway gave (import_denied, an authentication error)
or a connection that was never made still ends the run with that error, and
nothing was applied. The result carries
slug deploy_project_unverified and, after an overwrite, restore_command
(ign project import <name> --file <run_dir>/export/<name>.zip --collision-policy overwrite --yes, relative to the repo root, with no
--profile; the human output names the gateway), and the process exits 6
after the envelope: see the unverified verdict
exception. In
human mode, stderr carries progress lines prefixed ign deploy projects: for
the pre-image, the import, the verify and the record (ids, counts and flags
only, never project content); under --json stderr stays empty. A release record that cannot be written
after a verified import is a warning and record_written is false: the
project is deployed, and the next run plans a record. A node declared with
ssh takes the record like a local one: it is written in the deploy
account's home, so the record needs no write right on the gateway's data
directory. Because that write rides an sftp batch line, a project name (in
deploy/project.yaml) or a target gateway holding an sftp glob character
([ ] { } * ?), a quote, a backslash or a control character is refused on an
ssh node as deploy_tree_invalid before any gateway or host is contacted.
Pre-checks. A confirmed run reads the gateway before it writes anything,
with the same checks and the same flags as deploy apply,
and a refusal leaves no run directory, no pre-image, no import and no record
(deploy_preflight_failed, exit 6, naming the check). --check runs none of
them.
wrong_targetruns for every confirmed run, arecord-only run included, because the record is a write to the host. The gateway's name over REST (gateway-info) must be thesystemNameof the node's ownignition/system-properties(read through the node's read half) and, when the target declaresnodes.master.gateway_name, that name too. A gateway that reports no name, a host whosesystemNamecannot be read, or two names that differ refuse. The refusal says which comparison failed and never repeats a name the gateway or the host reported.designersruns forcreateandimport, before the import. Any open Designer session refuses by default (--designers fail).--designers waitpolls every 10 s until none is open or--designers-timeout SECS(1 to 86400, default 600) passes, and then refuses with the same session list.--designers proceedgoes ahead and records the open sessions (user, project, host; names only) inrun.jsonandevents.ndjson. It takes no reason string. The check covers every open session, not only those on this project. Arecord-only run does not touch the project, so it skips the check and says so inevents.ndjson(designers_skipped) andrun.json.- An unanswerable read (the gateway or the host cannot be reached) is its own
error (
network_error,transport_failed), neverdeploy_preflight_failed.
Children of a parent project. After an imported or imported_unverified
outcome the run looks at the projects whose parent is the project it just
replaced (D-06): the gateway's project list names them, and only direct
children count (a grandchild such as WHK-Distillery01-HMI under
WHK-Controls is not looked at when WHK-Global deploys). A project nothing
inherits from, a created, recorded or skipped outcome and --check run
no sweep, and data.children is then the empty list.
The gateway keeps re-reading a child for a few seconds after its parent is
overwritten, so the sweep does not compare at once. It exports every child
every 5 s and compares each export's canonical digest with the one before; when
two consecutive reads of every child agree it stops, and it never waits longer
than 30 s (CHILDREN_SETTLE_S, six times the 5 s that capture C9 measured; the
two values are fixed and have no flag). A child still changing at the bound is
classified on the last read and a warning says it did not settle. Each child
then gets one entry in data.children, byte-sorted by name, as {name, status, release, recorded_sha256, live_sha256}:
ok: the child's digest equals theartifact_sha256of the release record that the lastigndeploy of that child left.mismatch: they differ. The child changed since its last deploy, whether or not the parent's overwrite is the cause, and a warning names it and the release it was last deployed at.unverified: there is nothing to compare. The child has no record (this tool never deployed it), or its export or its record could not be read; in the last case a warning carries the error code only.release,recorded_sha256orlive_sha256isnullfor what is missing.
In human mode the output gains a children: block with one line per child
(ok, MISMATCH or unverified, and the release or no record), and stderr
carries a progress line when the wait starts and one per child. The sweep
reports and nothing else: a drifted child is a warning, never a failure; it
does not change the outcome or the exit code (an imported_unverified parent
still exits 6 on its own verdict), starts no rollback, and writes nothing. Its
only gateway requests are the project list and the children's exports, and its
only host reads are the children's release records; no child is imported,
restored or modified. It reports hash drift only. What an open Designer or
Perspective session of a child sees after its parent is overwritten is not
claimed either way. The warnings are in data.warnings like any
other, so a CI job summary that lists warnings shows them.
Cutting a project over from the git module
Cutover is per project and it is administrator work (D-11, D-12). ign never
deletes, renames or edits a .git, offers no flag that does, and refuses a
project that still has one (project_git_managed).
Which procedure applies (K8 a). The git module stays installed and an
administrator removes .git from one project at a time while the module keeps
serving the others. That is the procedure because of what capture C10 observed
with the signed git module 2.3.4 on an 8.3.6 gateway, in a template variant and
in a production variant (production_mode: true, production_branch: production, production_tagPattern: "v*"), and the production keys of the
real gateway were confirmed by the owner to be the set that was exercised:
api-import-reverted: no. An API overwrite import of a module-managed project was not reverted by a gateway restart, by the module's startup sync or by a project scan.import-removes-git: yes. The overwrite import itself removes the project's.gitdirectory. This is whydeploy projectsrefuses a project that still has one: an import over a module-managed project would end its management by accident.git-recreated: no. A removed.gitwas not recreated, and the module then logs that the project has no.gitdirectory and skips it. A project without a.gitwas never touched by the module.
So nothing the module does at gateway start undoes a deploy once the project's
.git is gone, and the module need not be uninstalled. What C10 did not
exercise: the Designer-side pull, save and hotfix paths of the module,
tags_importOnStartup, commissioning_enforceBranch: true and the real
production git.yaml (the owner confirmed its keys; none was pasted). A
gateway whose module configuration uses any of those is outside this
procedure; K8 b, uninstalling the module gateway-wide before any project cuts
over, is the fallback for it.
Per project, in this order:
- Pick the release. The production module is driven by
v*tags, so the tag it last pulled for this project is the release to use. Check the project repo out at that tag. The cutover only works if this tag is what the gateway runs; step 4 proves it. - Confirm the deploy account's grant. The
.gitprobe needs the list right ondata/projectsand on the project directory (Project deploys, under the account scope ofdeploy apply), and no write right there. Use one deploy account per gateway: the release record is written in that account's home (ign-deploy/<gateway>/projects/<project>/release.json), so a different account later sees no record and plans the first deploy again. - An administrator removes the project's
.git(data/projects/<project>/.git) on the gateway host, with the host's own tools, while the module keeps serving the other projects. Until this is done the check below refusesproject_git_managednaming the location and the kind. - Check. Run
ign deploy projects --env <ENV> --release <TAG> --check(add--targets DIRwhen the target files live elsewhere). Expectaction: recordandwould_change: true: the gateway already runs exactly the artifact, so a deploy writes only the record (D-12, K14). Readmembers(allsame) andremoved_count(zero) as the proof.action: importmeans the project is not what the release holds; without a record that refusesdestructive_change_refusedwith gatefirst_deploy, and the right answer is the tag that matches, not a flag.--allow-first-change <REASON>(1 to 500 characters, kept inrun.jsonand the result) is for a difference a reviewer has read in the check and accepts: the first deploy then replaces the project. - Apply with the reviewed hash. Run
ign deploy projects --env <ENV> --release <TAG> --artifact-sha256 <HASH> --yes, where<HASH>is theartifact_sha256that step 4 printed. Expect outcomerecorded,ok: true, no import request and the release record written on the master node's host. From here the tool owns the project's deploys. - Disable the git module's tag pull for this project (spec section 13
step 8). The same
v*tag now starts theigndeploy job, and the module's pull for the project must not also fire, or one tag has two deployers. This is human work on the module's side (its pull trigger and its configuration for the project). A project without.gitis skipped by the module until it is cloned again, and a fresh clone would make it module-managed again. - Later releases are an ordinary
--checkfollowed by an apply. The tool never touches.git; a.gitthat reappears refuses the next run.
OPEN, an owner question this runbook does not settle. On 2026-10-22, asked about the production git module keys, the owner said: "We won't want this to be on a stray production branch forever. May need to think this through." That question is OPEN. The long-term branch lifecycle of module-managed projects is not decided by Phase 25 and not by this procedure, and it does not change K8.
What a project repo needs
Before a project repo's first deploy it holds:
project.jsonat the root (the project's own export layout, with theignition/directory and the module directories beside it).deploy/project.yamlwithschema_version: 1, the gatewayprojectname and theenvironmentsit may deploy to (the example above). Target files are not kept here: they live in the infrastructure repo and are passed with--targets DIR(D-07)..ign/in.gitignore. The run directory.ign/deploy/<gateway>/<run-id>/holds the pre-image export of the live project, which can hold credentials a script embeds. A confirmed run creates it only after git shows it is ignored; otherwise it refusesdeploy_preflight_failedand nothing is written. A tree that is not a git work tree is not refused.deploy/** -textin.gitattributes, so a Windows checkout withcore.autocrlfcannot put CR line endings intodeploy/project.yaml, which refuses them.- An optional
.ignexcludeat the root to keep more paths out of the artifact (see the grammar above). Readdata.excludedin the check to see what stays out.
Manifest.projects pins in a gateway tree are not read by this verb (D-04):
the artifact is built from the checkout at run time and its digest is the pin.
Open question: images and themes
OPEN, with no owner yet. WHK-Global's images/ and themes/ directories are
gateway resources, not project content: a project export does not carry them,
and the artifact leaves any root images/ or themes/ directory out (it is
not a project directory). The git module's production pull imports tags,
themes and images (commissioning_importThemes, commissioning_importTags and
commissioning_importImages are true in the exercised production keys). After
WHK-Global's cutover nothing in this tool delivers its images/ and themes/.
Someone must decide how, or whether, they reach the gateway before that
cutover; this section records the gap and does not close it.
Running deploy in CI
Two reusable GitHub Actions workflows ship in this repository:
.github/workflows/deploy-plan.yml and .github/workflows/deploy-apply.yml.
They plan and apply gateway configuration with ign deploy; projects are
not deployed by them. A gateway-config repository calls them with a short
caller workflow and pins both the workflow ref and ign-version to the same
release tag. The first release tag that contains the two files is cut
separately, so replace v1.6.0 below with that tag.
name: gateway deploy
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
plan:
uses: TheThoughtagen/ignition-cli/.github/workflows/deploy-plan.yml@v1.6.0
permissions:
contents: read
pull-requests: write
with:
env: production
ign-version: v1.6.0
runs-on: '["self-hosted", "windows"]'
master-profile: prod
master-url: https://gateway.example.com:8043
# Optional: read targets/<env>.yaml from the infrastructure repository.
targets-repository: WhiskeyHouse/whk-onprem-infra-iac
targets-ref: v2026.10.1
targets-dir: ignition
secrets:
IGN_SSH_KEY: ${{ secrets.IGN_SSH_KEY }}
IGN_TARGETS_TOKEN: ${{ secrets.IGN_TARGETS_TOKEN }}
apply:
needs: plan
if: github.event_name != 'pull_request'
uses: TheThoughtagen/ignition-cli/.github/workflows/deploy-apply.yml@v1.6.0
permissions:
contents: write
pull-requests: write
with:
env: production
environment: production
ign-version: v1.6.0
plan-sha256: ${{ needs.plan.outputs.plan_sha256 }}
runs-on: '["self-hosted", "windows"]'
master-profile: prod
master-url: https://gateway.example.com:8043
targets-repository: WhiskeyHouse/whk-onprem-infra-iac
targets-ref: v2026.10.1
targets-dir: ignition
secrets:
IGN_MASTER_TOKEN: ${{ secrets.IGN_MASTER_TOKEN }}
IGN_BACKUP_TOKEN: ${{ secrets.IGN_BACKUP_TOKEN }}
IGN_SSH_KEY: ${{ secrets.IGN_SSH_KEY }}
IGN_TARGETS_TOKEN: ${{ secrets.IGN_TARGETS_TOKEN }}
The three targets-* inputs are optional and the example shows them for
WHK-Global, whose targets live in whk-onprem-infra-iac
(--targets DIR). Leave targets-repository out and the
workflows read targets/<env>.yaml from the caller repository, exactly as
before. When it is set, the workflows check that repository out read-only into
.ign-targets at the workspace root with IGN_TARGETS_TOKEN (no persisted
credentials) and pass --targets .ign-targets/<targets-dir>. ign reads the
declared tree only from <repo-dir>/gateways/<gateway>/, so .ign-targets is
not part of it and is never walked. WHK-Global's tag deploy and its project
deploy are two separate callers with separate approvals: this one deploys
gateway configuration, and each project repository deploys its own project
through deploy-projects.yml.
Before enabling apply, create the GitHub environment named by
environment (here production) in the caller repository, under Settings,
Environments, and add its required reviewers. A job that names an environment
that does not exist gets an unprotected one created for it, so an apply with no
reviewers configured runs without approval.
On a pull request the plan job posts one sticky comment per env, updated in
place, with the counts, the changing entries and the plan_sha256; the same
text goes to the job summary. On a push to main the plan job re-plans the
merged commit, and the apply job waits for the production environment's
reviewers, who read that plan in the plan job's summary. The apply passes the
plan job's plan_sha256 as --plan-sha256 (Decision C): a
gateway that changed between the review and the approval makes the apply stop
as plan_stale, with nothing written.
Inputs. Apply-only inputs are marked.
| Input | Required | Default | Meaning |
|---|---|---|---|
env | yes | environment name; selects targets/<env>.yaml | |
ign-version | yes | release tag to install, for example v1.6.0 | |
environment (apply only) | yes | GitHub environment whose protection rules approve the apply | |
plan-sha256 (apply only) | yes | the reviewed plan's plan_sha256, from the plan job's plan_sha256 output | |
master-profile, master-url | apply: yes | empty | profile name and gateway URL of the master; a profile and its URL come together |
backup-profile, backup-url | no | empty | the same for a target with nodes.backup; the names must differ |
runs-on | no | '["self-hosted"]' | a JSON list of runner labels, for example '["self-hosted", "windows"]' |
repo-dir | no | . | the declared repo root, relative to the checkout |
ssl-verify | no | true | verify gateway TLS certificates |
ssh-key-env | no | empty | the env var NAME that the target's nodes.*.ssh.key_env names |
targets-repository | no | empty | owner/name of the repository that holds targets/<env>.yaml and the known_hosts files; empty reads them from the caller repository |
targets-ref | no | main | branch, tag or commit of targets-repository; pin a tag or commit for a reproducible deploy |
targets-dir | no | ignition | folder of targets-repository that holds targets/ (the --targets directory) |
designers-timeout (apply only) | no | 600 | seconds to wait for open Designer sessions, 1 to 86400 |
Secrets. The workflows read four fixed secret names. The example maps
them explicitly, which works from any caller. secrets: inherit passes them
too, but only when the caller repository is in the same organization or
enterprise as this repository; a caller anywhere else must map them as shown
(deploy-plan.yml takes IGN_SSH_KEY and IGN_TARGETS_TOKEN, deploy-apply.yml all four).
| Secret | Used for |
|---|---|
IGN_MASTER_TOKEN | exported as IGNITION_TOKEN_<MASTER_PROFILE_UP>, the first step of the credential resolution order; apply only |
IGN_BACKUP_TOKEN | the same for backup-profile, for a target with nodes.backup; apply only |
IGN_SSH_KEY | the private key text, exported under the ssh-key-env name for an ssh target with key_env |
IGN_TARGETS_TOKEN | a same-organization token with read access (contents) to targets-repository only; required when targets-repository is set |
Tokens are never written to a file. The generated config.toml holds profile
names and URLs only, at a job-temp path set through IGNITION_CLI_CONFIG, so
the runner user's own config is not read; no keyring is used. known_hosts is
the repo-committed file the target names. A secret defined on a GitHub
environment reaches a reusable workflow only through that workflow's own
environment:. The apply job has one; the plan job has none, so IGN_SSH_KEY
for the plan must be a repository or organization secret.
Runner requirements.
- Either a self-hosted Windows x64 runner with Git for Windows (bash, curl,
sha256sum, git) and the Win32-OpenSSH client in
System32\OpenSSH. The workflow puts that directory ahead of Git's ownsshand refuses an ssh target whenssh -Vdoes not reportOpenSSH_for_Windows. - Or a Linux x64 or arm64 runner with bash, curl, tar, sha256sum, git and the
OpenSSH
sshandsftpclients. The install was exercised inbuildpack-deps:noble-curl(Ubuntu 24.04, glibc 2.39). - Actions runner 2.327.1 or later (the artifact and script actions run on Node 24).
- Outbound https to the GitHub release downloads.
- A caller
.gitignorethat lists.ign/; otherwise the apply fails therun_dir_not_ignoredpreflight. - Do not attach self-hosted runners to public repositories. A pull request from a fork receives no secrets and a read-only token.
What the apply job does.
- It runs under the caller's GitHub
environment:, so the environment's required reviewers approve it, once the environment exists and has reviewers (see Before enabling apply above). - It runs in one concurrency group per env with
cancel-in-progress: false. GitHub keeps one run going and one pending; a newer pending run replaces an older pending one. - It passes
--designers wait --designers-timeout <designers-timeout>. - It always takes the
.gwbk; the workflow never passes--skip-gwbk,--allow-destructiveor--allow-drift. - It installs the pinned release binary only after the asset's SHA-256 matches
the release's published
.sha256file; a mismatch fails before extraction.
Exit and slug handling. The job classifies the result from the exit code,
the stdout data.outcome and data.slug, and the stderr error.code, as
ign emits them. The rolled_back and applied_backup_unsynced rows are the
rolled-back and
backup-unsynced
verdict exceptions: a success envelope on stdout with exit 6.
What ign returned | Read from | What the job does |
|---|---|---|
exit 0, applied or noop | stdout data.outcome | passes |
exit 6, rolled_back (deploy_rolled_back) | stdout data.outcome, data.slug, data.cause | fails: restored automatically, nothing needs undoing |
exit 6, applied_backup_unsynced (deploy_backup_unsynced) | stdout data.outcome, data.backup.reason | fails: master is live, backup lagging, do not rerun |
exit 6, deploy_preflight_failed | stderr error.code and error.message | fails naming each failed check; nothing written. A designers line in the message is the Designer wait timing out |
exit 6, plan_stale | stderr error.code | fails: the plan changed since review; nothing written |
exit 2, destructive_change_refused | stderr error.code | fails: the plan has destructive or drifted entries and the workflow never passes the allow flags |
| any other exit 2 | stderr error.code | fails as a pipeline bug |
| anything else | stderr error.code, error.message, error.hint | fails showing all three; the message carries recovery commands such as the one for deploy_journal_present |
Notifications. A failed apply posts a new comment that @mentions the actor who triggered the run, and the re-running actor when different. The comment goes on the pull request (the event's own, or the one that merged the commit), otherwise on the commit, and the same text is always in the job summary. A notification API error is a warning and never replaces the deploy's own failure message. GitHub's own failure email comes in addition.
Artifacts. The apply job uploads run.json, result.json and
events.ndjson from the run directories it created, plus the two ign
envelopes. It never uploads snapshot/ or the .gwbk. They stay owner-only in
the runner's workspace until the next checkout cleans it, so prefer ephemeral
runners.
Rollback. The apply job summary prints the run_id and the command
ign deploy rollback --env <env> --run <run_id> --yes. Only the run_id is
needed, because the on-box record is the source of truth; see
deploy rollback. Run it from a checkout of the
gateway-config repository, with the same profiles and ssh access. A cancelled
run can leave a journal; the next plan or apply prints the recovery command.
Testing status. The workflows were linted with actionlint and shellcheck, and their scripts were exercised on Linux and macOS. Run the first deploy from a Windows runner against a disposable gateway.
Running deploy projects in CI
.github/workflows/deploy-projects.yml deploys one Ignition project from its
own repository to one environment. A project repository calls it once per
environment and chains the calls, so a release tag is promoted from dev to
prod as separate jobs, each with its own check against its own gateway and
its own approval. The workflow has two jobs:
checkinstalls the pinnedignrelease, checks out the project and the targets, and runsign deploy projects --check --json(GET requests only, nothing written). It puts theaction, the member counts, every warning and theartifact_sha256of the checkout in the job summary and exposes the hash as the job outputartifact_sha256.applyneeds: check, runs under the caller'senvironment:(the approval gate) and runsign deploy projects ... --artifact-sha256 <hash from check> --yes --json. A checkout that is not the artifact the check hashed isproject_hash_mismatch(exit 6) with nothing uploaded; a live project that changed since the check isplan_stale(exit 6) with nothing imported.
A project repository pins the workflow ref and ign-version to the same
release tag. The first release tag that contains the file is cut separately, so
replace v1.6.0 below with that tag.
name: project deploy
on:
push:
tags: ['v*']
jobs:
dev:
uses: TheThoughtagen/ignition-cli/.github/workflows/deploy-projects.yml@v1.6.0
permissions:
contents: read
with:
env: dev
environment: dev
release: ${{ github.ref_name }}
ign-version: v1.6.0
runs-on: '["self-hosted", "windows"]'
master-profile: dev
master-url: https://dev-gateway.example.com:8043
ssh-key-env: IGN_DEPLOY_KEY
targets-ref: v2026.10.1
secrets:
IGN_CHECK_TOKEN: ${{ secrets.IGN_CHECK_TOKEN_DEV }}
IGN_CHECK_SSH_KEY: ${{ secrets.IGN_CHECK_SSH_KEY }}
IGN_TARGETS_TOKEN: ${{ secrets.IGN_TARGETS_TOKEN }}
prod:
needs: dev
uses: TheThoughtagen/ignition-cli/.github/workflows/deploy-projects.yml@v1.6.0
permissions:
contents: read
with:
env: prod
environment: prod
release: ${{ github.ref_name }}
ign-version: v1.6.0
runs-on: '["self-hosted", "windows"]'
master-profile: prod
master-url: https://gateway.example.com:8043
ssh-key-env: IGN_DEPLOY_KEY
targets-ref: v2026.10.1
secrets:
IGN_CHECK_TOKEN: ${{ secrets.IGN_CHECK_TOKEN_PROD }}
IGN_CHECK_SSH_KEY: ${{ secrets.IGN_CHECK_SSH_KEY }}
IGN_TARGETS_TOKEN: ${{ secrets.IGN_TARGETS_TOKEN }}
release is the tag being deployed (github.ref_name of a v* tag); the
prod job starts only after the dev job, including its approved apply,
succeeded. The write-capable secrets IGN_MASTER_TOKEN and IGN_SSH_KEY are
not mapped in the caller: see the next heading for where they live.
Secrets and the approval gate. The check job runs as soon as the tag is
pushed, before and without any reviewer, so it must hold nothing that can
write. The workflow therefore uses two sets of secrets and never falls back
from one to the other:
| Secret | Job | What it is |
|---|---|---|
IGN_CHECK_TOKEN | check | a read-only gateway API token for that environment's gateway. --check sends GET requests only (projects/list, projects/find/<name>, projects/export/<name>). Required |
IGN_CHECK_SSH_KEY | check | the private key for the check's .git probe and its read of the release record, exported under the ssh-key-env name. Only for an ssh target |
IGN_MASTER_TOKEN | apply | the write-capable gateway API token |
IGN_SSH_KEY | apply | the private key of the deploy account, exported under the ssh-key-env name, for an ssh target |
IGN_TARGETS_TOKEN | both | a same-organization token with read access (contents) to the targets repository only. Required |
Store IGN_MASTER_TOKEN and IGN_SSH_KEY as secrets of the GitHub
environment named by environment (Settings, Environments, dev or
prod), and add required reviewers to that environment. A secret defined on an
environment reaches a reusable workflow only through that workflow's own
environment:; only the apply job has one, so the write token and key are
released to a job only after a reviewer approves it, and the caller does not
map them. Do not store them as repository or organization secrets: those are
available to every job that maps them, approved or not. Give the check job
its own read-only gateway token, one per environment, as repository secrets
(the example names them IGN_CHECK_TOKEN_DEV and IGN_CHECK_TOKEN_PROD).
The check ssh key has two reasonable shapes, with a trade-off each:
- The same deploy key as
IGN_SSH_KEY. One account to provision and rotate, and the check sees exactly what the apply will see (same account, same release record). The cost is that the key stored for the unapproved job can write the gateway's mode collection, so anyone who can get a workflow to reach that secret before approval has the deploy account. - A second key for the same deploy account that the host restricts to read
access (for example a forced read-only sftp command in
authorized_keyson a Linux host; this repository has not exercised that). The unapproved job then holds nothing that can write. Do not use a different account: the release record lives in the deploy account's home (ign-deploy/<gateway>/projects/<project>/release.json), so another account would not see it and the check would report no previous release.
deploy-plan.yml maps IGN_SSH_KEY into its ungated plan job in the same way;
this section does not change that. A local node needs no ssh key.
Before enabling apply, create the GitHub environments named by
environment in the project repository and add their required reviewers; a job
that names an environment that does not exist gets an unprotected one created
for it. Restrict who may push v* tags (a tag ruleset) and, on the prod
environment, which tags may deploy to it.
What the reviewer approves. The check job summary prints the action
(create, skip, record or import), the live and artifact hashes, the
member counts, every live member an overwrite would delete, and the warnings.
Approve the apply job only if that artifact_sha256 is the artifact you
reviewed. The apply job runs even when the check reports skip, so a reviewer
may be asked to approve a run that changes nothing: ign then ends with the
outcome skipped and writes nothing, not even a release record.
Concurrency. The apply job runs in one concurrency group per environment
and repository, ign-deploy-projects-<env>-<repository>, with
cancel-in-progress: false. GitHub keeps one apply running and one pending per
group, and a newer pending apply replaces an older pending one: if
v1.4.0 is waiting while v1.3.0 deploys and v1.5.0 is then pushed, the
v1.4.0 apply is cancelled and never runs, and v1.5.0 takes its place.
Re-run the v1.4.0 workflow run if it must still be deployed. The record the
deploy writes is replaced atomically, so a torn read is not possible even when
a check overlaps an apply.
Targets. targets-repository (default WhiskeyHouse/whk-onprem-infra-iac),
targets-ref (default main) and targets-dir (default
ignition) name the folder that holds targets/<env>.yaml and the
known_hosts files (--targets DIR).
Each job checks it out read-only with IGN_TARGETS_TOKEN, a sparse checkout of
targets-dir only, and no persisted credentials. The token is never printed.
The project is checked out to project/ and the targets to targets/ beside
it, because actions/checkout refuses a path outside the workspace; repo-dir
(default .) is relative to the project checkout.
The check job resolves targets-ref to a commit, shows it in the check summary
and exposes it as the targets_sha output. The apply job checks out exactly
that commit and stops if the checkout is anywhere else, so a branch that moves
between the check and the approval cannot change the host, data_dir,
known_hosts pin or gateway name the approved artifact lands on.
| Input | Required | Default | Meaning |
|---|---|---|---|
env | yes | environment name; selects targets/<env>.yaml and the entry in deploy/project.yaml | |
environment | yes | GitHub environment whose protection rules approve the apply | |
ign-version | yes | release tag to install, for example v1.6.0 | |
release | yes | the release tag being deployed, for example github.ref_name | |
master-profile, master-url | yes | profile name and gateway URL of the master | |
runs-on | no | '["self-hosted"]' | a JSON list of runner labels |
repo-dir | no | . | the project repo root, relative to the project checkout |
targets-repository, targets-ref, targets-dir | no | see above | where the targets are read from |
designers-timeout | no | 600 | seconds to wait for open Designer sessions, 1 to 86400 |
ssl-verify | no | true | verify gateway TLS certificates |
ssh-key-env | no | empty | the env var NAME that the target's nodes.*.ssh.key_env names; it receives IGN_CHECK_SSH_KEY in the check and IGN_SSH_KEY in the apply |
The workflow outputs artifact_sha256, outcome (created, imported,
recorded, skipped, imported_unverified and the refusals) and run_id.
Child projects. The apply job summary lists one line per child project of
the deployed project with its status, and the job emits one ::warning::
annotation for each child whose live content no longer matches its last
deploy. Children are reported and never changed.
Failures. An exit 6 deploy_project_unverified verdict (the gateway
answered but the project read back is not the artifact) fails the job with the
slug and the restore command from the envelope, or says nothing can be restored
when no pre-image was saved. The workflow never passes --allow-downgrade or
--allow-first-change: a downgrade or a first change to a project is refused
(destructive_change_refused) and an administrator runs it by hand.
The job uploads run.json, result.json and events.ndjson of the run
directory it created and the two ign envelopes, never the saved pre-image.
What a project repository needs. The checklist is What a project repo needs; a project that is still managed by the git module is first moved over with Cutting a project over from the git module. The runner requirements are those of Running deploy in CI.
Testing status. The workflow is linted with actionlint and shellcheck, and
its steps are run on a Linux or macOS host against a mock gateway. These can
only be shown on GitHub and have not been: the sparse cross-repository checkout
and the masking of the targets token, environment secrets reaching the
reusable workflow's apply job, the concurrency queue and its replacement of a
pending run, the check job's output reaching the apply job through
workflow_call, and the behaviour of a self-hosted Windows runner. Run the
first deploy against a disposable gateway.