Export the Estate
ajat export writes every Automation rule in your Jira Cloud tenant to a scope-aware directory tree of JSON files, plus a state file that drives incremental re-runs. The goal is not only disaster recovery: the export becomes the raw inventory for understanding how Jira Automation is distributed across global, project, multi-project, and other scopes.
This is the main operational workflow for ajat and the foundation every other command builds on. Reports, diff, search, and bulk rule operations all read the local tree this command produces.
ajat supports Atlassian Jira Cloud Automation only. It does not support Jira Server or Jira Data Center.
If you are setting ajat up for the first time, start with Installation and First-Time Setup, which walks from install to your first successful export and inventory report.
Large Jira sites often accumulate automation over years. Rules are created by different teams, copied between projects, and widened to multi-project or global scope as business processes grow. ajat export captures that full estate in one repeatable run so teams can review the automation mesh from disk instead of jumping between project administration screens.
First-time export
ajat export --output-dir ./jira-automations-backup--output-dir is required and must be provided explicitly. The command first paginates the Jira Automation rule summary endpoint to build the full work list, then downloads each rule concurrently.
🔍 Fetching rules...
📥 Fetched 100 rule(s)...
📥 Fetched 200 rule(s)...
...
📥 Fetched 951 rule(s) ✅
Discovered 951 rule(s)
Exporting rules ... 23.66% [225 in 2.1s; ~ETA: 7s]The discovery banner, the per-rule progress bar, and the per-rule status lines all go to stderr, so piping stdout (for example into tee, jq, or a file) stays clean.
export is a read-only command. It never mutates Jira — it only reads rule
definitions and, by default, resolves the account IDs those rules reference.
On-disk layout
Rules are written into a scope-aware tree so you can answer basic governance questions before opening a report: which rules are global, which projects own local rules, which automations span more than one project, and which definitions need closer inspection.
<output-dir>/
├── .ajat_state.json # incremental state
├── .ajat_external_directory.json # account directory and pictures (omitted with --resolve-users=false)
├── .ajat_projects.json # project catalog: type, style and icon of every project the rules are scoped to
├── .ajat_audit_stats.json # execution statistics (only after `ajat audit-log import` or `fetch`)
├── global/
│ └── <uuid>__<rule-name>.json # rules with no scope ARI
├── projects/
│ └── <KEY>__<Project-Name>/
│ ├── <uuid>__<rule-name>.json # single-project rules
│ ├── <uuid>__<rule-name>.external-info.json # resolved identities (omitted with --resolve-users=false)
│ └── <uuid>__<rule-name>.ops-info.json # search sizes (only with --probe-jql)
├── multi-project/
│ └── <KEY1>__<Name1>__<KEY2>__<Name2>__and-N-more/
│ └── <uuid>__<rule-name>.json # multi-project rules
└── other-scope/
└── <uuid>__<rule-name>.json # rules with non-project scope ARIsFilenames are normalized (NFKD to ASCII, special characters to -) so the tree is safe on Linux and macOS.
For multi-project rules, the project labels in the directory name are sorted alphabetically. The Atlassian API does not guarantee a stable order for ruleScopeARIs between responses, so the sort makes the path deterministic across runs — which is also what makes incremental skip work.
Incremental and sync modes
Re-running with the same --output-dir only re-downloads rules whose updated timestamp has changed in the upstream listing. Rules already on disk with the same updated are skipped. Two mutually exclusive flags control how rules that have disappeared from the upstream listing are handled:
| Flag | Default | Effect on changed rules | Effect on rules removed from Jira |
|---|---|---|---|
--incremental | true | Re-download only changed rules | Tombstone in state (status: deleted); local JSON file is kept |
--sync | false | Re-download only changed rules | Tombstone in state and delete the local JSON file |
# Default: incremental, keep deleted rules on disk for auditing
ajat export --output-dir ./jira-automations-backup
# Sync mode: mirror upstream exactly, including deletions
ajat export --output-dir ./jira-automations-backup --sync
# Disable state entirely: re-download every rule, no orphan reconciliation
ajat export --output-dir ./jira-automations-backup --sync=false --incremental=falseThe default --incremental keeps tombstoned files on disk because deleted automation can be part of the audit story — a workflow changing, a project being decommissioned, a cleanup that needs review. Switch to --sync when you want the on-disk tree to mirror Jira exactly, for example when the export feeds a downstream pipeline that should never see stale rules.
This mirrors the --sync / --incremental model used by the cousin
acs2md tool’s space convert: --sync deletes local files
for upstream deletions, --incremental keeps them.
Choosing a mode
| Scenario | Recommended mode |
|---|---|
| Compliance or audit archive that must retain removed rules | --incremental |
| Live mirror feeding a downstream pipeline | --sync |
| Git-backed backup that should track Jira exactly | --sync |
| Conservative continuity copy that never loses history | --incremental |
The state file
The state file (.ajat_state.json) records what was downloaded, when, and with what result. It is written as a single line of JSON, which keeps it friendly to git diff and grep tooling that expects one record per line.
When --sync or --incremental is active, the final summary block prints the resolved state file path so it is easy to copy into a debugger or grep command:
Export complete
discovered : 951
downloaded : 12
skipped : 939
failed : 0
deleted : 0
elapsed : 8.3s
output : /Users/me/jira-automations-backup
state file : /Users/me/jira-automations-backup/.ajat_state.json
external : /Users/me/jira-automations-backup/.ajat_external_directory.jsonThe state file line is omitted when state tracking is disabled (--sync=false --incremental=false). The external line is shown only when --resolve-users was requested.
By default the state file lives at <output-dir>/.ajat_state.json. Override its location with --state-file only if you need a non-co-located state.
Resumability
.ajat_state.json is rewritten atomically (temp file, then rename(2)) after every single rule the exporter processes — successful download, failure, or scope reclassification. There is no end-of-run flush you can lose: if you press Ctrl-C, or the process is killed, or the laptop loses power, every rule that finished is durably on disk with status: "downloaded", and the next run skips it.
Rules that ended in status: "failed" are retried on the next run. The retry preserves the prior last_error field in state until the rule succeeds, so the state file is also the audit log for what is currently broken.
To force a full re-download while keeping the JSON files, delete just
.ajat_state.json and re-run. To start completely fresh, delete the whole
output directory. You can also ignore state for a single run with
--sync=false --incremental=false.
Why is a rule re-downloading?
If you expected an incremental re-run to skip almost everything and the download counter is climbing fast, pass -v (--debug-changes). Every rule that fails the skip-check is printed with the precise reason:
↻ redownloading "Triage Bugs" — updated 1.6739e+09 → 1.6741e+09
↻ redownloading "Onboarding" — path projects/X/... → projects/Y/...
↻ redownloading "Audit Log" — local file missing
↻ redownloading "Send Slack" — prior status: failed| Reason | What it means |
|---|---|
new | The state file has no entry for this UUID (counted as a download). |
prior status: <X> | The previous run did not finish as downloaded (e.g. failed), so the rule is retried. |
updated <a> → <b> | Jira reports a fresher updated timestamp than the state remembers. |
path <old> → <new> | The rule’s scope-derived directory changed (project renamed, scope ARI added or removed). |
local file missing | State says the rule was downloaded but the JSON file is no longer on disk. |
Leave -v off for routine runs — the running tally and final summary are enough — and turn it on when investigating churn.
Resolving user identities
Every exported rule is full of opaque Jira account IDs — the rule’s author, the identity it runs as (actor), its collaborators, and any account IDs buried inside component configuration. On their own these read like 712020:9c32… and tell you nothing.
ajat turns them into display identities automatically. --resolve-users is on by default, because the resolved names are what make the backup and the reports actually readable:
# Resolution runs as part of a normal export — no extra flag needed.
ajat export --output-dir ./jira-automations-backup
# Opt out when you only want the raw rule JSON, or the token lacks the
# read:jira-user scope:
ajat export --output-dir ./jira-automations-backup --resolve-users=false
# Keep the identities but skip the pictures:
ajat export --output-dir ./jira-automations-backup --avatars=falseWhen enabled, a post-download phase collects every distinct account ID referenced across the run, resolves them in one batched, concurrent, cached pass (a tenant with hundreds of rules but a handful of distinct accounts costs roughly one round-trip), and writes the resolved identities next to each rule. With --avatars (on by default) it also downloads each resolved account’s 48×48 picture, so reports can show faces offline; the download goes to Atlassian’s public avatar CDN or to Gravatar, and credentials are sent only to your own site, never to those hosts. The exported rule JSON is never modified, so the backup stays a byte-for-byte copy of what Jira returned.
Two kinds of file are produced:
- Per-rule sidecar
<uuid>__<rule-name>.external-info.json, written beside the rule it describes. It records the rule’s author, actor, collaborators, and component references, each with the resolved display name, account type (atlassian/app/customer), active flag, and — where it appears — the JSON path inside the rule. Sidecars are pretty-printed because they are meant to be read by hand next to each rule. - Directory index
.ajat_external_directory.jsonat the output root: a tenant-wideaccountId → identitymap plus a per-account count of how many rules reference it, so reports and tooling can answer “who owns the most automation?” without opening every sidecar. It is also where each account’s picture lives, as anavatarDatadata URI of about 4 KB — once per account, never in the sidecars — which is what lets the reports show faces without fetching anything. Pass--avatars=falseto keep the identities and leave the pictures out. Like.ajat_state.json, it is written as a single line of JSON.
Account IDs the API cannot resolve — deactivated, deleted, or app accounts the token cannot see — are recorded as unresolved placeholders ("resolved": false) rather than failing the run. Enrichment is additive, idempotent, and re-runnable: each file carries a schemaVersion and resolvedAt timestamp, so re-running --resolve-users refreshes changed display names without re-downloading rules. Any enrichment failure is logged but never fails the backup — the rule JSON is already safely on disk by then.
Every report picks these files up: resolved actor and author names with their pictures, a Top authors panel, per-rule Author / Collaborators rows, and the ownership findings in the consistency and risk reports. Backups exported without --resolve-users render with raw account IDs. Run ajat export once to pick up the pictures for a backup made before they existed.
Sidecars store display names and — when Jira exposes them — email addresses by design. Treat an enriched backup as containing personal data and share it accordingly. Email addresses are frequently hidden by Atlassian’s privacy settings and will simply be absent when that is the case.
The project catalog
Every run also writes .ajat_projects.json at the output root: one entry per project the rules are scoped to, with its id, key, name, type (software, business, service_desk), and style (classic for company-managed, next-gen for team-managed). It costs no extra API call — the exporter already fetches each project to name its scope directory — and it is written with or without --resolve-users. Reports use the type and style to link each rule to its own page in Jira, which lives under the project’s settings and whose path depends on both (see Links into Jira).
With --avatars the catalog also carries each project’s icon (iconData, a PNG of about 1 KB fetched from your site’s own avatar endpoint), so the project chips in every report show it. An icon already in the previous catalog for the same URL is reused.
Common flags
| Flag | Short | Default | Description |
|---|---|---|---|
--output-dir | required | Root directory for the JSON tree — must be provided explicitly | |
--state-file | <output-dir>/.ajat_state.json | Override only if you need a non-co-located state | |
--page-size | 100 | Jira summary page size (1..100) | |
--workers | 4 | Concurrent per-rule downloads (1..64); see Rate limits | |
--redact-sensitive-fields | false | Ask Jira to mask secrets in the exported JSON body | |
--resolve-users | true | Resolve referenced account IDs to display identities and write *.external-info.json sidecars plus a directory index. Pass --resolve-users=false to skip the extra read:jira-user API calls | |
--avatars | true | With --resolve-users, download each resolved account’s picture and each project’s icon into the export so reports show faces and icons offline. Pass --avatars=false to keep the identities and skip the images | |
--probe-jql | false | Count how many work items each scheduled search and JQL branch matches and write *.ops-info.json sidecars for the risk report (see Probing search sizes). One request per distinct search | |
--incremental | true | Track state, skip unchanged rules, keep local files for rules removed from Jira. Mutually exclusive with --sync | |
--sync | false | Track state, skip unchanged rules, delete local files for rules removed from Jira. Mutually exclusive with --incremental | |
--debug-changes | -v | false | Print a one-line reason for every rule re-downloaded due to a state mismatch |
Masking sensitive fields
--redact-sensitive-fields asks Jira to mask the fields it considers secret in the returned rule JSON. Do not rely on it: on a real export it left every webhook URL that carried a key and a token in place. Treat the export directory as confidential whatever the flag, and use the risk report’s Exposed secrets finding to find the rules that hold a credential (see Sharing reports securely).
Progress reporting
The progress label updates in place with the running tally:
Exporting · ↓12 skip 939 fail 0 ... 99%↓N— rules fetched from the Automation API this run.skip N— rules whose state showed them unchanged and whose local JSON file is still on disk.fail N— rules where the API call or the local write failed.
When --resolve-users is active the run has two phases — downloading rules, then resolving the accounts they reference — and the display shows three bars together: a combined Overall aggregate, the Automations download tracker, and the External info enrichment tracker. All three are created up front so the display never collapses between phases.
Everything renders to stderr. Suppress the progress bar and the per-page discovery lines entirely with the persistent --no-progress flag — useful for CI/CD logs, scripting, and terminal recordings. Each per-rule transition is also logged as a structured event when --log-file is set, so disabling the bar never loses operator-visible information.
Probing search sizes
A scheduled search or a JQL branch reruns the steps after it for every work item it returns, so its size decides how much a rule costs and whether Jira throttles it. The rule JSON only holds the JQL, not its size. Pass --probe-jql to ask Jira:
ajat export --output-dir ./jira-automations-backup --probe-jqlAfter the download (and identity resolution) the export:
- collects the searches that fan out: each scheduled JQL trigger that is not limited to “work items that have changed since the last run”, and every JQL branch, at any depth;
- wraps each in the rule’s project scope,
project in (<ids>) AND (<jql>), because Jira runs a project-scoped rule’s JQL inside its projects, and counting the bare JQL would count the whole site; - counts each distinct query once through
POST /rest/api/3/search/approximate-count(rules copied across projects often share a query); - writes a
<uuid>__<rule-name>.ops-info.jsonsidecar beside the rule, with each search, the query counted, and its count.
The summary gains a line:
jql probe : 28 searches counted across 35 rulesWhat the counts mean:
- They are Jira’s estimate and can lag very recent edits.
- They count what your account can see. A rule that runs as another account may see more or fewer work items.
- Searches with smart values (
{{issue.key}},{{lookupIssues.key}}) only have a value while the rule runs; they are recorded as dynamic and not counted. - Jira refuses to count a search with no restriction at all (an empty query or a bare
ORDER BY); that is recorded as unbounded. - A failed count never fails the export. It is recorded with Jira’s error in the sidecar’s
errorfield, and the risk report falls back to reading that search’s JQL. The usual causes are JQL that is no longer valid (a renamed field, a deleted project) and transient errors; re-run the export to retry.
ajat report risk reads the sidecars. A counted search over 1000 work items is flagged with its real size, an unbounded one is flagged as such, and a counted search under 1000 is not flagged whatever its JQL looks like. Without --probe-jql the report falls back to reading the JQL text (see Risk). On a real 654-rule estate the text alone flagged nine searches; the probe showed that eight of them matched between 0 and 46 work items, and confirmed the ninth at 3,588. The probe is off by default and costs one request per distinct search.
Like the identity sidecars, ops-info files are never imported as rules, are refreshed on every probed run, are removed when a rule no longer has a search to count, and are deleted with their rule under --sync.
Rate limits and concurrency
The Atlassian Automation API throttles per tenant. ajat export is designed to back off cleanly instead of crashing the run:
Retry-Afteris honored. A 429 pauses the offending request for the advised wall-clock duration (plus jitter, capped at 120 seconds per wait), then retries, with a 5-minute cumulative retry budget per request and a 60-attempt safety belt.- A shared back-off gate parks every worker on the same wall. When one worker is rate-limited, parallel workers wait at the same deadline instead of hammering the API in lock-step. This is what makes high
--workersvalues safe. X-RateLimit-NearLimit: truetriggers a preemptive cushion — the pool slows for roughly 750 ms to avoid tipping into a 429 wave.- 5xx and network errors retry independently with jittered exponential backoff, up to four attempts per request.
The default --workers 4 matches Atlassian’s recommended low-concurrency profile. Healthy tenants can usually raise it to --workers 16 without seeing 429s; very large or constrained tenants should consider --workers 2. The exporter is fully resumable — kill the run, lower the worker count, and re-launch with the same --output-dir to pick up where you left off.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Every discovered rule was downloaded or skipped successfully |
| 1 | Bootstrap / configuration / authentication failure |
| 2 | At least one rule failed to download (see the state file for last_error) |
| 130 | Interrupted (SIGINT / SIGTERM) |
Scheduled exports
ajat export is safe to run from cron, GitHub Actions, or any other scheduler. Because it is incremental and resumable by default, a recurring job keeps a current inventory cheaply.
# .github/workflows/jira-automations-backup.yaml
on:
schedule: [{ cron: "0 4 * * *" }] # 04:00 UTC daily
workflow_dispatch:
jobs:
export:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: |
# Download URL comes from your Climakers order email; store it as a CI secret.
curl -sL "${{ secrets.AJAT_DOWNLOAD_URL }}" -o ajat.zip
unzip ajat.zip && chmod +x ajat && sudo mv ajat /usr/local/bin/
- run: ajat export --output-dir ./jira-automations-backup --workers 16 --redact-sensitive-fields --no-progress
env:
AJAT_JIRA_DOMAIN: ${{ secrets.AJAT_JIRA_DOMAIN }}
AJAT_JIRA_USERNAME: ${{ secrets.AJAT_JIRA_USERNAME }}
AJAT_JIRA_API_TOKEN: ${{ secrets.AJAT_JIRA_API_TOKEN }}
- uses: actions/upload-artifact@v6
with: { name: jira-automations-backup, path: ./jira-automations-backup/ }For scheduled CI, use the CI/CD Automation license edition so re-validation does not hit the licensing API on every run. See Licensing for editions.
Pair the exported artifact with a downstream job running HTML Reports or Compare Snapshots when you need a fresh view for platform reviews, change advisory meetings, or compliance evidence.
What is not exported
- Rule execution history. Jira retains roughly 90 days of audit log, and it is not part of the rule definition.
ajat audit-logbrings it in separately, as per-rule statistics for the risk report, from Jira’s CSV export or straight from the log (experimental). - Workflow, project, or board configuration. ajat is scoped to Automation rules only.
- Custom-field schemas. The full rule JSON references custom-field IDs but does not carry their schemas.
ajat v1 is export-only for backup, audit, and analysis. Restoring exported rules back into a tenant is handled by Import and Restore.
Related
- Installation and First-Time Setup — from install to your first export.
- HTML Reports — turn the export tree into inventory, collision, consistency, risk, and workflow reports.
- Rule Execution History — import or fetch the Jira audit log so the risk report sees how rules actually run.
- Compare Snapshots — diff two export directories for drift review and CI gates.
- Bulk Rule Operations — run label, name, scope, and state selectors against an export directory.
- Import and Restore — push an export tree back into Jira, safely and plan-first.
- Configuration — precedence, environment variables, and proxy settings.