Skip to main content

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):

FieldMeaning
schema_versionrequired, always 1
standards_versionoptional free-form label; informational
typesrequired list of allowed module/type resource types
tag_providersoptional list of tag provider directory names
projectsoptional list of pinned project artifacts (name, version, sha256, artifact)
verify.tag_probesoptional list of tag paths read back after a deploy

targets/<env>.yaml (schema_version: 1, same strictness):

FieldMeaning
schema_versionrequired, always 1
gatewayrequired directory name under gateways/
moderequired deployment mode name
nodes.master, nodes.backupmaster 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.*.sshhost, 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_hostsrequired: 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_soptional, 1..120, default 10: ssh ConnectTimeout in seconds
nodes.*.ssh.op_timeout_soptional, 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_nameoptional: 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.*.localdata_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.json with 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 manifest types (D-02);
  • the security types ignition/security-properties, ignition/security-levels, ignition/api-token and ignition/user-source, even when the manifest lists them: rendering them can lock an administrator out of the gateway, so they stay gateway-owned. A manifest types entry for one of them is refused too, naming manifest.yaml;
  • config-mode.json at 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 .gitkeep is the one exception: it is skipped at any depth and never rendered or listed. Only the first character decides, so a module directory such as com.inductiveautomation.perspective is fine;
  • a tag provider directory not listed in the manifest tag_providers, a directory under tags/<provider>/ that is not one of the four tag types, and tag types placed under resources/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 types or 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.json or attributes.uuid (see Resource identity).
  • an Embedded secret, a plaintext value at a secret-typed schema path, or a plaintext password/secret string in an undescribed module type (see Secrets); this is embedded_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:

RuleLayoutattributes.uuid
Singletonfiles directly under <module>/<type>/none; a resource.json that carries one, or is missing, is refused
Namedone directory per resourcekept if present; derived and inserted if absent
Named, no uuid (ignition/store-and-forward-engine, com.inductiveautomation.perspective/themes)one directory per resourcenever derived or inserted; a resource.json is required; an existing valid uuid is kept

What render does for a Named resource:

  • resource.json present with a valid attributes.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.json present without one, in the gateway's writer format: the uuid is inserted into attributes right after lastModification (and legacyId when present) and the rest of the file is unchanged. A resource.json that 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, with scope from the type's rule, version 1, restricted false, overridable true, files listing the resource's files in byte order, and attributes holding the uuid and enabled: true. The gateway loads such a file without lastModification or a signature (decision CD2). The data key uuids_minted lists these resources as module/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 object data, at any depth of any .json member of any resource or tag file, is refused. Embedded is 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.json must hold absent, null or a well-formed Referenced at each path; a string (even an empty one) or any other value is refused as (plaintext):

    TypeSecret paths in config.json
    com.inductiveautomation.alarm-notification/alarm-notification-profilesettings.settings.password, settings.twoWayPop3.pop3Password, settings.voipGateway.password
    com.inductiveautomation.opcua/devicesettings.advanced.password
    ignition/database-connectionpassword
    ignition/email-profilesettings.settings.password
    ignition/identity-providersettings.clientSecret
    ignition/oauth2-clientclientSecret
    ignition/opc-connectionsettings.authentication.password, settings.authentication.privateKeyPem, settings.security.keyStoreAliasPassword
    ignition/service-connectorsettings.password, settings.ssl.keyPassword, settings.ssl.keystore.key, settings.ssl.keystore.password, settings.ssl.truststore.password
    ignition/system-propertieserrorReportPassword
    ignition/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 password or secret (ASCII, any case) in config.json is refused as (plaintext, by key name). The secretName of 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 .) is CON, PRN, AUX, NUL, COM1-COM9 or LPT1-LPT9 in any case. The mode in targets/<env>.yaml is held to the same rules. An 8.3.6 gateway on Linux writes such a name encoded (a:b as a%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 Pump and pump; every colliding sibling is named. Whole names are compared with str::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 the ssh.data_dir of 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 mode production, a path under resources/ 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).

StatusMeaning
addedrendered, no effective live copy
changedrendered, the effective copy differs in file set or any byte
removednot rendered, and the declared-names ledger lists a file of the unit that still exists in the mode collection (the delete set)
samerendered, the effective copy has the same files, byte-equal
unmanagedlive, 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.

FlagValue
destructivetrue for every removed entry and every entry with delete-set files
destructive_reasonnull 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_risktrue 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 an ssh node and below the node's data directory for a local node. Never inside config/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 not config-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 under ignition).
  • 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 different gateway or mode than the target, unsorted or duplicated, or listing any path the rules above refuse, exits 6 deploy_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_dir is 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 2 deploy_tree_invalid. sftp is always given /C:/Program Files/...: the server resolves C:/... against the account home, so that form is never sent.
  • Listings. A Windows server prints long-listing names without their directory, so each ls -la is preceded by pwd and the Remote 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 -R follows 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 4 transport_failed naming 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 one IGN-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:

NodeLedger, 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 .json member 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 .json member where the render's secret scan reports any refused location or warning on either side (an Embedded secret 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 .json member (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 contains password, passwd, secret, token, apikey, api_key, credential, privatekey or private_key is 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 .json would 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 .json member 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:

ReasonMeaning
embedded_secretthe unit holds an Embedded secret or a plaintext secret location; nothing of it is written
not_round_trippablerender 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_rulea live type with no render rule (generated manifest only)
denied_typea 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.

CheckPasses whenFinding codes
scanno config scan is runningscan_active
modethe gateway runs the target's mode exactly (no mode is a mismatch)mode_mismatch
read_backevery declared resource is present over REST with an equal configresource_missing, resource_differs (JSON paths only)
filesevery declared tag unit on the host is byte-equal to the renderfiles_differ
loadedthe gateway log holds no will not be loaded line for a declared resourceunloaded_resource, logs_unavailable
probesevery 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:

  1. The flags are validated, and Decision C is checked (below).
  2. A journal left by an interrupted run is refused (deploy_journal_present, exit 6; the message prints the exact ign deploy rollback --run <id> --env <env> recovery command).
  3. The plan is rebuilt from the repo and the live tree. A --plan-sha256 that is not this plan's hash is plan_stale (exit 6), checked before anything else about the plan.
  4. An empty plan is outcome noop (exit 0). It sends no lock, backup, scan or restart request and creates no .ign/ directory.
  5. An entry that a core copy shadows (shadows_removal) is deploy_blocked (exit 6) whatever the flags.
  6. 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, are destructive_change_refused (exit 2). The reason is kept in run.json exactly as validated: not empty, at most 500 characters, no control characters (otherwise invalid_input).
  7. Without --yes, confirmation_required (exit 2) prints the plan: env, gateway, mode, each changing entry with its flags, the delete-set count and plan_sha256.
  8. The run directory is created (below), the preflights run, the .gwbk is 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 as deploy verify. A pre-image that no longer matches the plan under the lock aborts with nothing moved as plan_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.

CheckFails when
mode_masterthe master runs a different deploymentMode than the target's mode
mode_backupthe target names a backup node and it runs a different mode (skipped with no backup node)
wrong_targetthe gateway's REST name is not the host's systemName (or not nodes.master.gateway_name when set)
write_restthe gateway refuses the project scan with a 403
write_hostthe host account cannot write its staging and snapshot directories
designersa Designer session is open (per --designers, below)
hold_budgetthe change does not fit the lock hold (the size limit is in the Commands row)
master_activethe 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_readthe 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
secretsa 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>\projects for 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:

  1. A journal of another run is deploy_journal_present (exit 6) and prints the recovery command for that run (below).
  2. The run's on-box record is read. A record that is missing or damaged, for another gateway or mode, still applying with no journal, or already rolled back is deploy_run_invalid (exit 6), naming the run; a run is rolled back once.
  3. 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-changes is given. With the flag they are overwritten, and listed in the rollback's run.json, its events.ndjson and result.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.
  4. Without --yes, confirmation_required (exit 2) prints the run, the gateway, the mode, how many files it would restore and delete, the plan_sha256 of the run, and any live change it would overwrite.
  5. The rollback gets a run directory of its own (kind: rollback, same layout and .ign/ ignore rule as deploy apply), the same preflights and --designers policy as an apply (rollback runs master_active, so it refuses when the master is no longer the active node; backup_read and secrets are skipped, because a rollback renders no tree) (the hold_budget check 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 marked rolled_back with the rolling-back run's id. No .gwbk is 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.outcome cleared, 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. Any rollback_failed leaves the journal and the on-box snapshot snapshots/<run-id>/ (beside the journal) as they were, so first retry ign 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's snapshot/ 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_version must be 1.
  • project is 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.
  • environments lists every --env the project may be deployed with. An --env that is not listed refuses deploy_tree_invalid (exit 2) naming deploy/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 resources segment is stripped by name (a com.inductiveautomation.webdev/resources directory is real).
  • The root project.json, plus the top-level directories named ignition or 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.json contributes that file and exactly the files its files array lists (K2). Two refusals, both deploy_tree_invalid naming 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 a resource.json are left out.
  • .git, .github, .ign, node_modules, __pycache__ and *.pyc are never read, at any depth, so a previous run's .ign/ directory never enters the artifact. deploy is left out at the repo root only, because the root allowlist already does that; a script package or view named deploy below a module directory is kept. A symlink or special file in the walk refuses deploy_tree_invalid; it is never followed.
  • An optional .ignexclude at 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) refuses deploy_tree_invalid naming .ignexclude and the line number. .ignexclude cannot remove project.json, and a .ignexclude line that removes a file a resource.json lists refuses rather than shipping a resource the gateway would drop. .ignexclude itself must be a regular file, not a symlink.
  • Everything left out is reported in data.excluded (byte-sorted, at most 200 paths, excluded_count beside 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_invalid before 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 besides project.json (an import would empty the project). The built zip is also checked like projects import checks 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.

  • action is create (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) or import (it differs). would_change is false only for skip. live_sha256 is null when the project is absent.
  • members counts added, changed, removed and same members against the live project. removed lists the live members an overwrite would delete, which the gateway never reports itself (it answers 200 and success: true); it is the only place those deletions show.
  • excluded lists the repo paths left out of the artifact (see above).
  • unmanaged lists 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).
  • parent is the parent named in the repo's project.json, or null for a root project.
  • outcome is checked, ok is true, slug is null, run_id and run_dir are null, record_written is false and restore_command is null in 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 refuses destructive_change_refused (exit 2) with gate downgrade, naming the project, before any gateway request, and so will the apply. An equal release is allowed. --allow-downgrade lets the older release through; it is reported in data.allow_downgrade. data.previous_release is the release the record names, or null.
  • 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_refused with gate first_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 in data.allow_first_change. A project absent from the gateway is create and 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 record and would_change is 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 is skip and nothing is written.
  • Drift (K13). data.recorded_sha256 is the digest the record holds, and data.live_matches_last_deploy says whether the live project still equals it (null without 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:

  1. The artifact hash (D-01). --artifact-sha256 HASH is the artifact_sha256 that --check --json reported 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 refuses project_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 is invalid_input too.
  2. Confirmation. Without --yes the 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.
  3. A run directory. <repo>/.ign/deploy/<gateway>/<run-id>/ (mode 0700, files 0600, created only after git is shown to ignore .ign/) holds run.json, plan.json, events.ndjson and, on every end, result.json. run.json records the release, the artifact hash and the override flags.
  4. The pre-image. For an import the live project is exported into export/<project>.zip (0600) before anything is sent. If its canonical digest is not the one the plan was drawn against, the run refuses plan_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.
  5. The import goes to the master node's gateway only (nodes.backup is never contacted: the redundancy sync carries the project to it). An existing project is replaced (overwrite=true); an absent one is created (overwrite=false).
  6. The verify. The project is exported again and its canonical digest is compared with the artifact's. Equal is outcome imported (or created), and the release record is written on the master node's host, to a temporary file that is then renamed over release.json in one atomic step (mv -f on Linux, a Win32 MoveFileEx replace on Windows; on NTFS a replace still blocked by a reader after its retries keeps the previous record, and the run ends with record_written: false and a warning). A gateway answer that lists no change adds a warning and the comparison decides. When the project has a parent, the gateway's invalidParent flag is read after the import and a true value is a warning.
  7. 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 (outcome recorded).

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_target runs for every confirmed run, a record-only run included, because the record is a write to the host. The gateway's name over REST (gateway-info) must be the systemName of the node's own ignition/system-properties (read through the node's read half) and, when the target declares nodes.master.gateway_name, that name too. A gateway that reports no name, a host whose systemName cannot 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.
  • designers runs for create and import, before the import. Any open Designer session refuses by default (--designers fail). --designers wait polls 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 proceed goes ahead and records the open sessions (user, project, host; names only) in run.json and events.ndjson. It takes no reason string. The check covers every open session, not only those on this project. A record-only run does not touch the project, so it skips the check and says so in events.ndjson (designers_skipped) and run.json.
  • An unanswerable read (the gateway or the host cannot be reached) is its own error (network_error, transport_failed), never deploy_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 the artifact_sha256 of the release record that the last ign deploy 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_sha256 or live_sha256 is null for 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 .git directory. This is why deploy projects refuses a project that still has one: an import over a module-managed project would end its management by accident.
  • git-recreated: no. A removed .git was not recreated, and the module then logs that the project has no .git directory and skips it. A project without a .git was 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:

  1. 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.
  2. Confirm the deploy account's grant. The .git probe needs the list right on data/projects and on the project directory (Project deploys, under the account scope of deploy 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.
  3. 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 refuses project_git_managed naming the location and the kind.
  4. Check. Run ign deploy projects --env <ENV> --release <TAG> --check (add --targets DIR when the target files live elsewhere). Expect action: record and would_change: true: the gateway already runs exactly the artifact, so a deploy writes only the record (D-12, K14). Read members (all same) and removed_count (zero) as the proof. action: import means the project is not what the release holds; without a record that refuses destructive_change_refused with gate first_deploy, and the right answer is the tag that matches, not a flag. --allow-first-change <REASON> (1 to 500 characters, kept in run.json and the result) is for a difference a reviewer has read in the check and accepts: the first deploy then replaces the project.
  5. Apply with the reviewed hash. Run ign deploy projects --env <ENV> --release <TAG> --artifact-sha256 <HASH> --yes, where <HASH> is the artifact_sha256 that step 4 printed. Expect outcome recorded, 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.
  6. Disable the git module's tag pull for this project (spec section 13 step 8). The same v* tag now starts the ign deploy 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 .git is skipped by the module until it is cloned again, and a fresh clone would make it module-managed again.
  7. Later releases are an ordinary --check followed by an apply. The tool never touches .git; a .git that 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.json at the root (the project's own export layout, with the ignition/ directory and the module directories beside it).
  • deploy/project.yaml with schema_version: 1, the gateway project name and the environments it 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 refuses deploy_preflight_failed and nothing is written. A tree that is not a git work tree is not refused.
  • deploy/** -text in .gitattributes, so a Windows checkout with core.autocrlf cannot put CR line endings into deploy/project.yaml, which refuses them.
  • An optional .ignexclude at the root to keep more paths out of the artifact (see the grammar above). Read data.excluded in 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.

InputRequiredDefaultMeaning
envyesenvironment name; selects targets/<env>.yaml
ign-versionyesrelease tag to install, for example v1.6.0
environment (apply only)yesGitHub environment whose protection rules approve the apply
plan-sha256 (apply only)yesthe reviewed plan's plan_sha256, from the plan job's plan_sha256 output
master-profile, master-urlapply: yesemptyprofile name and gateway URL of the master; a profile and its URL come together
backup-profile, backup-urlnoemptythe same for a target with nodes.backup; the names must differ
runs-onno'["self-hosted"]'a JSON list of runner labels, for example '["self-hosted", "windows"]'
repo-dirno.the declared repo root, relative to the checkout
ssl-verifynotrueverify gateway TLS certificates
ssh-key-envnoemptythe env var NAME that the target's nodes.*.ssh.key_env names
targets-repositorynoemptyowner/name of the repository that holds targets/<env>.yaml and the known_hosts files; empty reads them from the caller repository
targets-refnomainbranch, tag or commit of targets-repository; pin a tag or commit for a reproducible deploy
targets-dirnoignitionfolder of targets-repository that holds targets/ (the --targets directory)
designers-timeout (apply only)no600seconds 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).

SecretUsed for
IGN_MASTER_TOKENexported as IGNITION_TOKEN_<MASTER_PROFILE_UP>, the first step of the credential resolution order; apply only
IGN_BACKUP_TOKENthe same for backup-profile, for a target with nodes.backup; apply only
IGN_SSH_KEYthe private key text, exported under the ssh-key-env name for an ssh target with key_env
IGN_TARGETS_TOKENa 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 own ssh and refuses an ssh target when ssh -V does not report OpenSSH_for_Windows.
  • Or a Linux x64 or arm64 runner with bash, curl, tar, sha256sum, git and the OpenSSH ssh and sftp clients. The install was exercised in buildpack-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 .gitignore that lists .ign/; otherwise the apply fails the run_dir_not_ignored preflight.
  • 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-destructive or --allow-drift.
  • It installs the pinned release binary only after the asset's SHA-256 matches the release's published .sha256 file; 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 returnedRead fromWhat the job does
exit 0, applied or noopstdout data.outcomepasses
exit 6, rolled_back (deploy_rolled_back)stdout data.outcome, data.slug, data.causefails: restored automatically, nothing needs undoing
exit 6, applied_backup_unsynced (deploy_backup_unsynced)stdout data.outcome, data.backup.reasonfails: master is live, backup lagging, do not rerun
exit 6, deploy_preflight_failedstderr error.code and error.messagefails naming each failed check; nothing written. A designers line in the message is the Designer wait timing out
exit 6, plan_stalestderr error.codefails: the plan changed since review; nothing written
exit 2, destructive_change_refusedstderr error.codefails: the plan has destructive or drifted entries and the workflow never passes the allow flags
any other exit 2stderr error.codefails as a pipeline bug
anything elsestderr error.code, error.message, error.hintfails 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:

  • check installs the pinned ign release, checks out the project and the targets, and runs ign deploy projects --check --json (GET requests only, nothing written). It puts the action, the member counts, every warning and the artifact_sha256 of the checkout in the job summary and exposes the hash as the job output artifact_sha256.
  • apply needs: check, runs under the caller's environment: (the approval gate) and runs ign deploy projects ... --artifact-sha256 <hash from check> --yes --json. A checkout that is not the artifact the check hashed is project_hash_mismatch (exit 6) with nothing uploaded; a live project that changed since the check is plan_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:

SecretJobWhat it is
IGN_CHECK_TOKENchecka 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_KEYcheckthe 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_TOKENapplythe write-capable gateway API token
IGN_SSH_KEYapplythe private key of the deploy account, exported under the ssh-key-env name, for an ssh target
IGN_TARGETS_TOKENbotha 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_keys on 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.

InputRequiredDefaultMeaning
envyesenvironment name; selects targets/<env>.yaml and the entry in deploy/project.yaml
environmentyesGitHub environment whose protection rules approve the apply
ign-versionyesrelease tag to install, for example v1.6.0
releaseyesthe release tag being deployed, for example github.ref_name
master-profile, master-urlyesprofile name and gateway URL of the master
runs-onno'["self-hosted"]'a JSON list of runner labels
repo-dirno.the project repo root, relative to the project checkout
targets-repository, targets-ref, targets-dirnosee abovewhere the targets are read from
designers-timeoutno600seconds to wait for open Designer sessions, 1 to 86400
ssl-verifynotrueverify gateway TLS certificates
ssh-key-envnoemptythe 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.