Version v0.5.4 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.

CI/CD & deploy examples

Build, push and register DAGs from CI — GitHub Actions, GitLab CI, Cloud Build.

Deploying a Dexaflow DAG is the same everywhere because a DAG is an immutable artifact: a dag.json + a container image, versioned together (ADR 0003).

By hand or in a pipeline, one command does it all:

dexaflow auth login --server https://pro.example.com   # by hand, once; stores the session
dexaflow deploy --yes                                  # compile → build → push → pin digest → register

dexaflow deploy is the pipeline-less promotion: it builds for the cluster’s architecture (--platform, else build.platforms in dexaflow.yaml, linux/amd64 when neither is set) with the first builder that applies, pins the image by digest, and registers the artifact. --yes skips the confirmation prompt (use it in automation). It is the recommended command in CI too, and the recipes below use it.

The explicit steps are still available when a pipeline wants the build and the register as separate stages. Both paths register the image by digest:

flowchart LR
  E[edit dag.py + dexaflow.yaml] --> D[dexaflow deploy]
  D --> R[control plane: registry/image@sha256:...]
  E -.-> C[dexaflow compile --build --push]
  C -.->|dag.json pinned| P[dexaflow push dag.json]
  P -.-> R
  1. dexaflow compile --build --push: parse dag.py, overlay dexaflow.yaml, run the guardrails (unknown task_id, unsupported operator, duplicate keys), build the DAG image, push it, and write the pushed image’s digest into dag.json as registry/image@sha256:….
  2. dexaflow push dag.json: register the artifact with the control plane.

In Pro you keep your DAGs in a Git repository, one directory per DAG, and CI turns each into an artifact when it changes. This is the recommended path: a push triggers the pipeline, no one builds by hand. Each DAG is just two files, no Dockerfile and no requirements.txt:

my-dags/                      # your Git repo, a workspace
├── dexaflow.workspace.yaml   # targets and shared defaults (registry, ...)
├── .github/workflows/
│   └── dexaflow-deploy.yaml  # the pipeline `dexaflow ci init github` writes
├── ci/
│   └── dexaflow-deploy.sh    # the deploy step every generated pipeline runs
└── dags/
    ├── my_pipeline/
    │   ├── dag.py            # the DAG (TaskFlow / operators)
    │   └── dexaflow.yaml      # id, python_version, dependencies, registry
    └── another_pipeline/
        ├── dag.py
        └── dexaflow.yaml

dexaflow deploy (and compile --build) generates the Dockerfile from dexaflow.yaml: FROM the published Dexaflow base, your deps/connectors installed, your DAG copied in. No Dockerfile to maintain. A Dockerfile in the DAG directory is used verbatim instead and the yaml’s dependencies then do not reach the image, so ship one only if you want full control (see your own Dockerfile).

The built image is your artifact: push it wherever you like (Docker Hub, ECR, Artifact Registry, ACR, GHCR, a private registry). deploy needs a registry prefix from one of --registry, DEXAFLOW_REGISTRY, the registry: block of dexaflow.yaml, the target, the workspace defaults, your config file or the cluster’s deploy defaults (Registry, platforms and base image), and names the image after the dag_id unless registry.image_name says otherwise; it has no --image flag. The only image Dexaflow owns is the base your DAG layers on.

The mental model: a push to main runs one pipeline, which deploys only the DAG projects that push changed (deploy --all --changed-since <before>, see Deploying only what changed): each one is compiled, built from its yaml, pushed to your registry and registered. The control plane runs the new version on the next trigger. The projects deploy one after the other, and a broken another_pipeline does not stop my_pipeline from deploying; the job still fails, naming the DAG that did not deploy. dexaflow ci init writes that pipeline for you.

The first registration of a DAG leaves it paused, as in Airflow: its schedule creates no run until someone unpauses it (UI switch, or dexaflow admin dags unpause <dag_id>). Later registrations never change that, so a pipeline that redeploys an active DAG keeps it active. A DAG that should start active sets is_paused_upon_creation=False; see New DAGs start paused.

What a deploy says, and the flags that change it

Every deploy prints the server it goes to (and the target that chose it) before it builds, and ends with a line saying what happens next:

next: etl is paused, so nothing runs until `dexaflow admin dags unpause etl`; unpausing creates 13 catchup runs since start_date 2026-10-01T00:00:00Z, then one run per tick; next tick 2026-10-14T00:00:00Z (UTC).

An active DAG gets its next run time instead. The line follows the scheduler: with catchup=True and a start_date, every tick since start_date gets a run; otherwise only the latest tick does. A DAG that already ran resumes after its last run, so its count is an upper bound, as it is for --active on a DAG that was paused. Times are in UTC: the scheduler does not yet apply a DAG’s timezone or end_date, so the line ignores them too.

With no server set anywhere (no --server, --target, DEXAFLOW_SERVER_URL or server_url), deploy stops before building instead of going to http://localhost:8080; a machine set up for Lite keeps the local default.

Checks before the build (preflight)

A deploy that the server or the registry would refuse fails in seconds, before the build, with a message that names what to fix. deploy checks, in this order:

  1. Before the compile: the control plane answers and the token may deploy. A token the server refuses (401) names dexaflow auth login; a token without the deployer role, deploy or write on dags, or an API token without the deploy scope (403) names dexaflow auth create-token --scope deploy. With --paused or --active the check also covers what those flags need (write on dags and, for an API token, the dexaflow:run scope), and the refusal says so. A server that cannot be reached stops the deploy here.
  2. Before the build: the registry accepts a push to the image’s repository with the credentials the daemonless build paths push with: the docker config (~/.docker/config.json and its credential helpers) and, for an Artifact Registry, GCR, ECR or ACR host, the session of the gcloud, aws or az CLI, as registry.auth_method says (see Registry credentials). The line says where the credential came from. The check starts an upload and cancels it (best effort: a registry that refuses the cancel expires the empty upload), so no image is written. A refusal of the credential found stops a docker deploy, naming the login command of the registry (gcloud auth configure-docker <host>, aws ecr get-login-password --region <region> | docker login ..., az acr login --name <name>, or docker login <registry>). With no credential found, or with another --builder (podman, for one, keeps credentials of its own in auth.json), a refusal is a note: naming the command for that builder, and why the cloud CLI gave no token when it could not (not on PATH, not signed in), and the push decides. Nothing is checked when nothing is pushed: with --skip-build, or when the registry already holds the image for the content label.
  3. After the compile, before the build: the server’s dry run of the register. The compiled dag.json goes to POST /api/v2/dags/{dag_id}/versions/dry_run, which runs every check the register runs (spec validation, the operator’s image, service account and field allowlists, the resource unit, source mode, the tenant’s limits, the declared connections and variables, the version label) and writes nothing. The register gets the image pinned by digest, which the build makes later. When nothing is built (--skip-build, or the registry already holds the image for the content label) the dry run is sent the digest the registry holds for the tag, the very image the register gets, so an allowlist that names that exact digest accepts it. Before a build it is sent the repository at a placeholder digest, so an allowlist of repositories or of repo@* decides as for the register. A refusal stops the deploy with the server’s reason, except two the built image decides when it is still to be built. One is a refusal of the placeholder image when it is the only refusal and the allowlist names the image’s repository at a digest (<repo>@sha256:...), which a reproducible build may match; a repository the allowlist does not cover still stops the deploy. The other is a version label registered with other content. Each of those is a note:, and the register answers it. So is a dry run the server could not answer (5xx, or 429 under load): the deploy goes on and the register runs the checks. A warning (a task that does not fit its size under executor.unit.enforce: warn) prints on stderr and the deploy goes on.

Each check that passes prints a preflight: line (on stderr with --output json). A server from before the dry run route answers it with 404 and registers nothing, also during a rolling upgrade when one replica is older: the deploy prints a note: and its policy checks run at the register, after the build, as before. dexaflow doctor runs the first two checks for the target in use, refreshing a saved session as a deploy does.

FlagWhat it does
--paused / --activeLeaves the DAG paused or active after this deploy, new or not, instead of the creation rule above. The register sets it in its own transaction, audited as dag.pause or dag.unpause; for content that is already current, nothing is registered and the DAG is paused or unpaused with the Airflow PATCH /api/v2/dags/{dag_id}. Pausing is a run-state write: these flags need write:dag (the deployer role alone is refused with 403), and a scoped token needs dexaflow:run as well as dexaflow:deploy, so a deploy-only token is refused with 403.
--planSays per DAG what a deploy would build, push and register, and why (content changed, already current, an older version, a new DAG), without running the parser, the builder or the register. Works with --all.
--changed-since <ref>With --all, deploys only the projects whose files changed since a git ref, and lists the others as skipped. See Deploying only what changed.
--output jsonPrints one JSON document on stdout for the pipeline to read; the human lines and the builder’s output go to stderr.

The JSON document has one entry per DAG:

{
  "server": "https://pro.example.com",
  "target": "prod",
  "dags": [
    {
      "dag_id": "my_pipeline",
      "status": "registered",
      "version": "v1.4.0-3f9c2a1b7d4e",
      "image": "ghcr.io/acme/my_pipeline@sha256:4e1f...",
      "digest": "sha256:4e1f...",
      "spec_hash": "9b0c...",
      "created": true,
      "dag_created": true,
      "is_paused": true,
      "next": "my_pipeline is paused, so nothing runs until ...",
      "execution": {
        "warm_pools": true,
        "tasks": [
          { "task_id": "extract", "mode": "warm" },
          { "task_id": "load", "mode": "dedicated", "reason": "node_selector set" }
        ],
        "pool": { "state": "on", "detail": "2 idle warm workers (up to 5) ...", "min_idle_workers": 2, "max_pool_size": 5 }
      }
    }
  ]
}

created says a new version was registered and dag_created that it was the DAG’s first. status is registered (a new version), unchanged (the server already had this exact content), current (the content label is the current version, so nothing was built), skipped (unchanged since the --changed-since ref, with reason), or failed (one DAG of --all, with error). With --plan it is planned or refused, and a plan object carries build, push, register and reason. After a failure of --all the document is still printed, so the pipeline sees which DAGs went through.

execution is the server’s answer on where each task runs: mode is warm (warm-eligible) or dedicated, with the reason for a dedicated pod, and pool.state is off, none or on, with previous_pool: "draining" for a new version of an existing DAG on a server that keeps warm workers: the previous version’s pool drains, and the new one starts at the next reconcile when the DAG declares min_idle_workers, else with its first run (see Prewarm and drain on deploy). The human output prints it as an execution line and, unless every task takes a dedicated pod for one reason, a warm pool line; see Where a deploy’s tasks run. A server from before the field sends none, and the entry has no execution. --plan does not run the parser, so it has no tasks to report on.

Deploying only what changed

In a repository with many DAG projects, deploy --all --changed-since <ref> deploys only the projects whose files differ from a git ref, so a commit that touches one DAG does not compile the others:

dexaflow deploy --all --changed-since origin/main --yes
==> orders: dags/orders/dag.py changed since origin/main
==> skipping billing (unchanged since origin/main)
==> deploying orders (/repo/dags/orders)
...
Deployed 1 DAG(s) from /repo (1 unchanged since origin/main, skipped)

A project counts as changed when a file its image or its version label is built from changed: the DAG source, the include_paths (shared code included), the dbt project and dbt_groups directories, dbt.manifest, its dexaflow.yaml (or leoflow.yaml), its .dockerignore, and its own Dockerfile (with a project Dockerfile, every file of the project directory the .dockerignore keeps). These are the files the content label hashes, so a skipped project is one whose label would not change. A file that exclude_paths leaves out, or that the generated Dockerfile does not copy (a README.md next to dag.py, say), changes nothing. A change to the workspace file (dexaflow.workspace.yaml, which holds the shared defaults and the targets) counts as a change to every project.

How the comparison works:

  • The ref is compared with the working tree, not with HEAD and not from a merge base: committed, staged, unstaged and untracked files all count, and so do submodules, whatever diff.ignoreSubmodules says. A change on the ref’s side since your branch started counts too, so the comparison errs toward deploying.
  • A file git ignores cannot be compared. A project built from one (a generated module under include_paths, dbt deps output) always counts as changed, and the output names the file. Inside a submodule, the submodule’s own ignore rules decide. So does a project built from a file outside the repository, or one whose folder resolves outside it.
  • The ref must be in the clone. A shallow clone (the default of actions/checkout) stops with a message saying to fetch the history: use fetch-depth: 0, or git fetch --unshallow. A ref that does not exist, or a folder outside a git repository, stops the deploy too; nothing is deployed by guess.
  • A project removed since the ref (its dexaflow.yaml or dag.py deleted) is reported with its dag_id, and nothing is deployed or deregistered for it: its DAG stays registered and scheduled until you pause it with dexaflow admin dags pause <dag_id> (or every such DAG at once with dexaflow sync <dir> --prune) or remove it with dexaflow dags delete <dag_id> --deregister. A project moved to another folder keeps its dag_id: it is reported as moved, with no removal advice, and deployed from its new folder. A dag_id changed in place is not reported: the project is deployed under its new id, and the DAG under the old id stays registered until you remove it.
  • It selects among the workspace’s projects, so it goes with --all, or a bare dexaflow deploy at the workspace root. With a dag_id or a path it is refused, rather than silently skipping the DAG you named.
  • What git does not see is not compared: the CLI version, --registry, --platform, the target’s settings and the config file. After upgrading the CLI or changing a target, deploy with --all alone once.

With --output json, every skipped project has an entry with status: "skipped" and its reason, the removed ones are listed under removed (path, dag_id, reason), and the moved ones under moved (dag_id, from, to).

--changed-since and the content label solve different costs. Without the flag, deploy --all already registers nothing for a project whose label is the current version, and reuses an image the registry holds for its label (ADR 0076); but it still compiles every project and asks the server about each. --changed-since skips those projects before any of that. Use it in the pipeline of a large repository, on every push; use --all alone when the inputs git does not see changed (above), or to check that every project is deployed.

The pipeline dexaflow ci init writes does this on every push to main, and the generated files are under Examples. What it passes to --changed-since decides what a failed or cancelled run leaves behind:

  • GitHub passes the commit of the last successful run of the workflow on the branch (gh run list --status success). A run that failed, or one the concurrency: group cancelled while it waited, leaves that commit where it was, so the next run deploys its changes too. With no successful run yet, every DAG is deployed.
  • GitLab passes the commit of the last successful push pipeline on the branch when the job has DEXAFLOW_GITLAB_API_TOKEN, a project access token with the read_api scope: the job token cannot read the pipelines API. Without it, the job passes the commit the branch was on before the push ($CI_COMMIT_BEFORE_SHA), and a failed, cancelled or skipped pipeline is not caught up by the next push, which compares with its own previous commit: rerun the failed pipeline, or run one by hand, which deploys every DAG. Turn off “Auto-cancel redundant pipelines” for the branch and set the process mode of the dexaflow-deploy resource group to oldest_first, so pipelines deploy in push order.
  • Cloud Build has no previous commit to pass: it deploys every DAG, unless you set _DEXAFLOW_BASE_REF on a build whose source is a git clone.

Every system deploys all the DAGs when the base commit is all zeros (a new branch) or missing from the clone (after a force push).

In a pipeline of your own, compare with the last commit that deployed rather than with the previous push: move a ref after each successful deploy (for example git push -f origin HEAD:refs/tags/deployed-prod in a last step) and pass --changed-since deployed-prod, with a concurrency: group on the job so two runs never move it out of order.

Rolling back

A rollback makes a version the control plane already holds current again. It rebuilds nothing: the version runs the image digest it was registered with.

dexaflow dags versions my_pipeline                 # what is registered, and which is current
dexaflow rollback my_pipeline                      # back to the version before the current one
dexaflow rollback my_pipeline --to v1.4.0-3fa2c1   # or to a label (or a version number) from the list

dexaflow dags versions lists each version, newest first: its number, its label, a * on the current one, when it was registered, the id of the user who registered it (dexaflow admin users list maps ids to emails), the commit it was built from (see Where a version was built) and the short image digest. --to matches a label first, then a version number. dexaflow rollback prints the version it moved from and the one it moved to, and takes --server and --token like the other commands. Both read and write through the API: the listing is GET /api/v2/dags/{dag_id}/dagVersions, and the rollback is PUT /api/v2/dags/{dag_id}/currentVersion with {"version": "<label>"} or {"version_number": <n>}, which needs the same permission as registering a version (scope dexaflow:deploy for a scoped token).

A rollback passes the checks a register of the version would pass today. Under deploy.require_digest: enforce, a version registered with a tag cannot be made current again: redeploy its sources with dexaflow deploy, or set the setting to warn (see Require images pinned by digest).

What a rollback changes, in one transaction:

  • New runs use the version made current, from the next scheduler tick.
  • The schedule and the other DAG settings (catchup, start date, max active runs, description, owner, tags) become that version’s.
  • Runs already created keep their version, including runs in flight.
  • Pause state does not change. A paused DAG stays paused, an unpaused one stays unpaused.

What the UI shows after a rollback follows Airflow, which has no rollback and always runs its newest version:

  • The DAG header’s Latest Dag Version and the version the Code tab opens on are still the newest version, not the current one. The Code tab’s version picker shows the source registered with each version (GET /api/v2/dagSources/{dag_id}?version_number=<n>; without version_number or with 0, the newest version’s source).
  • A run, and each of its task instances, shows the version it ran with, so a run created after the rollback shows the version made current, and its Code tab opens on that version’s source.
  • The Clear dialog offers “Run with latest bundle version” even on a run that already uses the current version, because the UI compares the run’s version with the newest one. In Dexaflow that option re-pins the run to the current version, so on such a run it changes nothing. When the current version adds or removes a task compared with the run, the option is refused with 409 before anything is cleared: clear without the option, or trigger a new run. The dialog does not say why (its preview stays empty and Confirm disabled); the detail of the API’s 409 names the tasks, and UI compatibility shows how to read it.
  • Which version is current is the * in dexaflow dags versions, and the is_current field of each version in GET /api/v2/dags/{dag_id}/dagVersions and of latest_dag_version in the DAG details. The MCP dag://source resource serves the current version’s source, as dag://spec serves its spec.

The version must still pass what a register of it would pass today: the operator’s execution policy, the tenant limits, and its declared Variables and Connections. A version that no longer does is refused with 400 (or 403 for a tenant limit), and the current version stays. Naming the version that is already current changes nothing. Every rollback is recorded in the audit log as dag.version.set_current, with the user and the from and to versions.

Two things that look like a rollback are not:

  • Re-running dexaflow push with an older dag.json is a no-op: content the control plane already has answers created: false, and the newer version stays current, schedule included.
  • Retagging an image in the registry changes nothing: a version registered by deploy names its image by digest.

dexaflow deploy of code that matches a version registered earlier (after a git revert, for example) stops before building and suggests the dexaflow rollback <dag_id> --to <label> command for that version.

Where a version was built

Every dexaflow deploy and dexaflow push records where the version was built. GET /api/v2/dags/{dag_id}/dagVersions (and .../dagVersions/{n}, and the answer of a rollback) returns it as build_info next to Airflow’s fields, null for a version registered without it; the dag_version embedded in task instance, run and DAG details answers leaves it out:

FieldWhere the CLI reads it
commitgit rev-parse HEAD in the project directory, when it is in a git work tree.
repoThe origin remote of that work tree, as an https URL: credentials and a trailing .git are dropped, and an ssh remote (git@host:org/repo.git) becomes https://host/org/repo. Outside git, GITHUB_SERVER_URL/GITHUB_REPOSITORY or GitLab’s CI_PROJECT_URL.
pipeline_urlThe CI run: on GitHub Actions GITHUB_SERVER_URL/GITHUB_REPOSITORY/actions/runs/GITHUB_RUN_ID, else CI_PIPELINE_URL (GitLab), BUILD_URL (Jenkins), BUILDKITE_BUILD_URL or CIRCLE_BUILD_URL, without a query string or fragment.
builderThe builder that produced the image, as the deploy prints it (code-layer, local-daemon (docker), …). Absent when the deploy built nothing: with --skip-build, and when it reused an image the registry already held for this content label.
base_imageThe base image of the generated Dockerfile, which carries the task agent. Absent when the project ships its own Dockerfile, and when the deploy built nothing (--skip-build, or a reused image).
cli_versionThe version of the CLI that built and registered the version.

What cannot be read is left out; a deploy never fails for it. dexaflow promote forwards the source version’s build_info to the destination.

Build info is metadata of the register, not part of dag.json: it changes neither the spec, its hash nor the version label, so the same sources built by two pipelines still compile to one version. A version is immutable, so a re-push of content already registered creates nothing and keeps the build info of its first register. The CLI sends it as the build_commit, build_repo, build_pipeline_url, build_builder, build_base_image and build_cli_version query parameters of POST /api/v2/dags/{dag_id}/versions; another client can send them too. The server checks them as external input (a commit is 7 to 64 lower case hex characters, the two URLs are absolute http or https URLs of at most 2048 bytes without credentials, query string or fragment, every field is valid UTF-8 without control characters) and refuses a bad value with 400. The register’s dag.version.register audit entry records the same build_info. It is the caller’s claim, recorded as sent and not verified: a deploy token can write any commit there.

Syncing a target with the workspace (GitOps)

dexaflow sync <dir> makes the target in use run what a folder of DAG projects holds, the way Argo CD or Flux make a cluster match a repository. It compares every project under <dir> with the DAGs registered on the target, says per DAG what it does, and does it:

dexaflow sync ./dags --dry-run --prune     # what would change; exit 1 if anything would
dexaflow sync ./dags --prune --yes         # make the target match
workspace: /repo/dags
target prod: https://pro.example.com (target prod, from --target)

  DAG      STATE             CURRENT VERSION             PAUSED  ACTION
  billing  drift             v1.3.0-4-g77aa01-1f0c (#4)  no      deploy v1.4.0-3fa2c1
  intake   not deployed      -                           -       deploy v1.4.0-9b0e12
  orders   behind            v1.4.1-c41d2e (#7)          no      none: registered content is newer than the workspace; run with --rollback to make v1.4.0-0d11f9 current
  reports  in sync           v1.4.0-5e6f70 (#2)          no      none
  legacy   not in workspace  -                           no      pause
  old_etl  not in workspace  -                           yes     none: already paused

dry run: 3 change(s) would be made (2 deploy, 1 pause); 1 behind, left as is (pass --rollback)
StateWhat it meansWhat sync does
in syncThe local content label is the current version.Nothing.
driftThe local content label is no registered version: the files changed, or the commit did (the label starts with git describe).Deploys it.
not deployedThe server does not know the DAG, or holds no current version of it.Deploys it.
behindThe local content label is a registered version older than the current one: the target was deployed from a newer commit than this checkout.Reports it and leaves it. With --rollback, makes that version current again, as dexaflow rollback --to <label> does: nothing is built and is_paused is kept.
not in workspaceThe target holds a DAG no project under <dir> defines.Reports it; with --prune, pauses it.
errorThe project could not be read (its dexaflow.yaml does not parse, say) or its DAG could not be read.Nothing; the exit code is 2.

A git revert does not make a DAG behind: the revert is a new commit, so git describe and the label change, and the DAG is drift and deployed again under the new label. A DAG is behind when the checkout itself is older than what was deployed, for example when Argo CD syncs an earlier revision.

The comparison is the one dexaflow status makes, and the label is the one dexaflow deploy computes, so “in sync” means a deploy would build and register nothing. What goes into the label decides when sync agrees with the job that deployed:

  • git describe of the project. A new commit moves the label even for a DAG whose files did not change: after each commit, sync deploys every DAG once, as deploy --all would. Run anywhere else (a hook, a second clone), sync must see the same history and tags as the job that deployed, or every DAG reads as drift.
  • The CLI version. Pin the same dexaflow version in CI and in the hook; after an upgrade every DAG reads as drift once.
  • --registry, --platform and --dockerfile, and the target’s, the workspace’s and the cluster’s deploy defaults. Pass sync the flags a deploy is given.
  • Only the default label. Sync deploys with the content label. A DAG deployed with --dag-version never reads as in sync, and sync would register it again under the content label: a team that labels its versions by hand should keep using deploy and use sync --dry-run --prune only to find the DAGs to retire.

How each change is made:

  • Deploys go through the same code as dexaflow deploy --all: compile, build and push (or reuse the image the registry already holds for the content label), pin the digest, register. A new DAG is paused or active as the server decides for new DAGs (is_paused_upon_creation, then dags_are_paused_at_creation); a redeploy never changes is_paused.
  • Rollbacks (--rollback only) make the matching version current with PUT /api/v2/dags/{dag_id}/currentVersion, as in Rolling back.
  • --prune pauses, it never deletes: PATCH /api/v2/dags/{dag_id} with is_paused: true, undone with dexaflow admin dags unpause <dag_id>. A DAG that comes back to the workspace later is deployed again but stays paused, since a deploy never changes is_paused: unpause it yourself. To remove a DAG for good, use dexaflow dags delete <dag_id> --deregister.
  • A change that fails is reported on its DAG and the other changes still go through.
  • At a terminal, sync shows the plan and asks before it changes anything (Apply 2 deploys and 1 pause on https://pro.example.com? [y/N]); --yes skips the question. --output json never asks: pass --yes or --dry-run.

What --prune guards against:

  • Every DAG the token can see is a candidate. Nothing marks which workspace deployed a DAG yet, so every DAG on the target that no project under <dir> defines is not in workspace, and --prune pauses a DAG deployed to the same target from another repository. Sync one folder per target (or per tenant), the folder that holds all of that target’s DAGs.
  • A project sync cannot read stops it. A dexaflow.yaml that does not parse hides the project’s dag_id (a project with a dag.py is found under its folder name, a dbt project not at all), so its DAG would read as not in workspace. With --prune, a project in error makes sync apply nothing at all and exit 2; without --prune the other changes go through.
  • A cap. With --prune, sync applies nothing when it would pause more DAGs than --max-prune (by default, the number of projects under <dir>).
  • A confirmation. Without a terminal, --prune needs --yes.
  • The DAG list. With --prune, a list of the target’s DAGs that cannot be read changes nothing; without it, that is a warning and the deploys and rollbacks go through.
  • An empty folder is refused, so a wrong path never reads as “nothing should run”.

Targets resolve as for a command run in <dir>, whatever folder sync runs in: --server, --target, DEXAFLOW_TARGET, DEXAFLOW_SERVER_URL, the target selected for the workspace around <dir> (dexaflow target use), its default_target, then server_url in the config file. The token is --token, then DEXAFLOW_TOKEN, then the one saved for that server, and the token decides the tenant. Like a deploy, sync refuses to guess the built-in http://localhost:8080.

Credentials. A deploy token can read, deploy and roll back, so it is enough for sync, sync --rollback and sync --dry-run --prune. Pausing needs write on DAGs (and the dexaflow:run scope for a scoped token), which the deployer role and a deploy token do not have: sync --prune with one reports each pause as failed (403) and exits 2.

Exit codes, for a CI check or a hook:

CodeMeaning
0The target matches the workspace (--dry-run), or every change was applied.
1--dry-run found something to change, or a behind DAG was left alone (no --rollback).
2The target or a project could not be read, a change failed, --prune refused to apply, the confirmation was declined, or the command was used wrongly.

A DAG that is not in workspace counts as a change only with --prune, and only while it is not paused, so sync --dry-run without --prune passes on a target that also runs DAGs from elsewhere.

--output json prints one JSON document on stdout (the deploy lines go to stderr): workspace, target, server, source, dry_run, prune, an error when the target could not be read, refused when --prune applied nothing, dags (each with dag_id, path for a project, state, local_version, current_version, matched_version for behind, is_paused, action (none, deploy, rollback or pause), result (planned, applied or failed), the deploy result of a deployed DAG, and error), a summary (in_sync, behind, deploy, rollback, pause, not_in_workspace, errors) and the exit_code. Fields are only ever added.

As a CI check. Run the dry run on a schedule, or after the deploy job, to catch a target changed by hand or a deploy that did not finish:

      - name: Is prod running what main holds
        env:
          DEXAFLOW_SERVER_URL: https://pro.example.com
          DEXAFLOW_TOKEN: ${{ secrets.DEXAFLOW_TOKEN }}   # a deploy token
        run: dexaflow sync ./dags --dry-run --prune

As an Argo CD hook. Argo CD runs a Job annotated as a hook after each sync of an Application. The control plane never reads git (the DAGs are built and registered from outside it), so the Job clones the repository at the revision Argo CD synced and runs dexaflow sync itself. Argo CD hands a Helm source the synced commit as $ARGOCD_APP_REVISION in its parameters, so ship the Job as a small chart in the DAG repository, for example deploy/dexaflow-sync/:

apiVersion: v2
name: dexaflow-sync
version: 0.1.0
revision: ""                       # set by Argo CD to the synced commit
repository: https://github.com/acme/dags
server: https://pro.example.com
image: registry.example.com/tools/dexaflow-cli:1.4.0   # your image: the CLI, git and Python
apiVersion: batch/v1
kind: Job
metadata:
  generateName: dexaflow-sync-
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: sync
          image: {{ .Values.image | quote }}
          env:
            - name: DEXAFLOW_SERVER_URL
              value: {{ .Values.server | quote }}
            - name: DEXAFLOW_TOKEN
              valueFrom: { secretKeyRef: { name: dexaflow-sync, key: token } }
            - name: REPOSITORY
              value: {{ .Values.repository | quote }}
            - name: REVISION
              value: {{ required "revision is set by Argo CD" .Values.revision | quote }}
          command: [sh, -c]
          args:
            - git clone --quiet "$REPOSITORY" /work &&
              git -C /work checkout --quiet "$REVISION" &&
              dexaflow sync /work/dags --rollback --prune --yes
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: dags
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/dags
    targetRevision: main
    path: deploy/dexaflow-sync
    helm:
      parameters:
        - name: revision
          value: $ARGOCD_APP_REVISION
  destination:
    server: https://kubernetes.default.svc
    namespace: dexaflow-sync

Notes on the hook:

  • A full clone, with history and tags, so the labels are the ones CI computed; the CLI image pins the same dexaflow version as CI.
  • --rollback: syncing an earlier revision in Argo CD is a rollback, so the hook makes the older version current instead of stopping on behind.
  • No builder in a pod. Let CI build and push the images (dexaflow deploy --all); the hook’s deploys then reuse the image the registry holds for each content label. A DAG whose image is missing fails with exit 2, which fails the hook.
  • No published CLI image yet: build one with the release binary, git and Python 3.11 to 3.13 (the compile parses dag.py).
  • --prune needs a token that may pause DAGs (see Credentials above). With a deploy token, drop --prune from the hook and run dexaflow sync ./dags --dry-run --prune in CI to report the DAGs to retire.

With Flux, which has no hooks and no revision to hand a Job, run sync in the CI job that pushes the commit Flux deploys, right after dexaflow deploy --all (or in its place), with --yes; and run dexaflow sync ./dags --dry-run --prune on a schedule to catch drift.

Promoting between environments

dexaflow promote registers on one target the exact version another target runs: the same dag.json and the same image digest. Nothing is compiled or built, so what reaches production is what ran in staging.

dexaflow promote my_pipeline --from staging --to prod --dry-run   # what would be copied and registered
dexaflow promote my_pipeline --from staging --to prod             # the current version of staging
dexaflow promote my_pipeline --from staging --to prod --version v1.4.0-3fa2c1   # or another version
  • Which version. Without --version it is the source’s current version. --version matches a label first, then a version number from dexaflow dags versions my_pipeline --target staging.
  • Only images pinned by digest. The version must run <repo>@sha256:<digest>, as every version dexaflow deploy, and compile --build --push followed by push, registers. A version registered with a tag (by a CLI from before #1703, or by a push that could not read the registry) is refused: a tag can name other bytes tomorrow.
  • The image, copied when needed. The destination registry is --registry, else the registry of the --to target, else the deploy.defaults.registry its control plane advertises. When one is set and the image is not already in it, the image is copied there by digest, with every layer and platform, byte for byte (bytes that do not match their digest are refused), and the copy is checked in the destination afterwards. The repository path is kept: ghcr.io/acme-dev/team-a/etl@sha256:… promoted to a target whose registry is registry.acme.dev/prod becomes registry.acme.dev/prod/acme-dev/team-a/etl@sha256:… (same digest). The copy is also tagged with the version label, unless that tag already names another digest there, which refuses the promote: a promote never moves a tag. The version is registered with the copy, and only its image field differs from the source’s dag.json. When the destination already holds this version, nothing is copied. Without a destination registry the same reference is registered, and the destination cluster must be able to pull it. The copy reads and pushes with the credentials of docker login and of a cloud CLI session, as a deploy does; a failure names the login command of each registry.
  • The normal register. The destination gets the version through POST /api/v2/dags/{dag_id}/versions, after its dry run (/versions/dry_run): a refusal (the execution allowlist, the tenant limits, a label already registered there with other content), or any answer that is not a dry run other than an older server’s 404 or 405, stops the promote before anything is copied. A DAG the promote creates starts paused if the server or the DAG says so (is_paused_upon_creation, else dags_are_paused_at_creation), as a deploy’s would, and an existing DAG keeps is_paused. Content the destination already has answers that nothing changed. When both servers run the same Dexaflow version, the destination records the same spec hash as the source when the image is not copied.
  • Audited, as claimed. The register’s dag.version.register audit entry carries promoted_from with the source target’s name and server URL as the promoting CLI sent them (promoted_from and promoted_from_server on the register). The destination records this claim; it does not verify it. The source version’s build_info (commit, pipeline, builder) is sent along, so the destination lists where the version was built.
  • A token for each control plane. --from-token and --to-token, else the session saved for each server by dexaflow auth login --target <name>. DEXAFLOW_TOKEN is not read, so one token is never sent to two control planes. The source side reads each version’s spec (GET /api/v2/dags/{dag_id}/dagVersions/{version_number}/spec), which holds the DAG’s source, environment literals and params, so it needs read on dags and, for an API token, the read scope: a deploy-only token is refused there with 403. The destination side needs only what a deploy needs: a deployer user or a token with the deploy scope.
  • For pipelines. --yes skips the confirmation prompt, and --output json prints one JSON document on stdout (the source and destination targets, servers, version and images, whether the image was copied, and the status registered, unchanged, planned or failed), with the human lines on stderr.

In a pipeline, a promotion job after the staging deploy:

      - name: Promote to prod
        env:
          STAGING_TOKEN: ${{ secrets.DEXAFLOW_STAGING_TOKEN }}
          PROD_TOKEN: ${{ secrets.DEXAFLOW_PROD_TOKEN }}
        run: |
          dexaflow promote my_pipeline --from staging --to prod --yes \
            --from-token "$STAGING_TOKEN" --to-token "$PROD_TOKEN"

Require images pinned by digest

A version registered with a tag (ghcr.io/acme/etl:v1) runs whatever the tag names when a node pulls it, so two nodes can run different bytes for one version. The server setting deploy.require_digest (DEXAFLOW_DEPLOY_REQUIRE_DIGEST, Helm deploy.requireDigest) decides what the control plane does with an image that is not pinned as <repository>@sha256:<64 hex> (sha256 only: another digest algorithm counts as not pinned):

ValueRegister of an unpinned imageMaking such a version current again
off (default)Registered, as every release before the setting did.Allowed, as before.
warnRegistered, with a warning in the answer’s warnings, which dexaflow push and dexaflow deploy print on stderr.Allowed, with a warning that dexaflow rollback prints on stderr.
enforceRefused with a 400 that names the setting and how to pin. Nothing is registered.Refused with a 400 that names the way out: redeploy the version’s sources with dexaflow deploy, or set warn.

Under warn and enforce the server logs each image it warns about (admitting an image not pinned by digest, with the DAG, the version, the image and the stage) and counts each warning and refusal in dexaflow_unpinned_image_total{stage="register|set_current",outcome="warned|refused"}. The dag.version.register and dag.version.set_current audit entries record require_digest and image_pinned. The dry run of a register (the deploy preflight) answers with the same warning or refusal, and records nothing.

dexaflow deploy, dexaflow promote and dexaflow compile --build --push followed by dexaflow push register pinned images (#1703). push resolves a tag in dag.json to its digest before registering it, so a file compiled by an older CLI is pinned too, as long as the registry can be read from where push runs. When it cannot, push registers the tag with a warning, and under enforce the refusal names why the image could not be pinned. Resolving a tag reads the registry (for up to 30 seconds), with the credentials of registry.auth_method in the project’s dexaflow.yaml: the one next to dag.json, else the one in the working directory, else the one `–project

` names. Pin by hand with `--image`, which skips that read when it is already a `@sha256:`; a tag given with `--image` must resolve, or the push fails:
ref=ghcr.io/acme/my_pipeline:v1
digest=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r .digest)
repo=$ref
case ${ref##*/} in *:*) repo=${ref%:*} ;; esac   # drop the tag, never a registry port
dexaflow push dag.json --image "$repo@$digest"

A CLI from before #1703 registers the tag that compile --build --push pushed: use warn to find the pipelines that still do, and upgrade their CLI or move them to dexaflow deploy.

Set enforce only once nothing on the cluster registers a tag any more: no increase of outcome="warned" while every pipeline has run.

A source mode version names the runtime image, which the server requires pinned by digest, so it passes every mode. Under dexaflow lite, the local dev loop registers a placeholder image: leave the setting off there.

Prerequisites

  • The dexaflow CLI on the runner (download the release binary, or go install).
  • Python 3.11 to 3.13 on the runner (dexaflow compile invokes the stdlib-only parser shim, ADR 0024, to turn dag.py into dag.json). See Python on the runner below.
  • A container registry your cluster can pull from, and a credential for it on the runner: the gcloud, aws or az session for Artifact Registry, ECR and ACR, the docker config (docker login, or a login action) for the others, and the container tool’s own login when it builds (Registry credentials).
  • The control plane URL, passed as --server or exported as DEXAFLOW_SERVER_URL (the pre-rename LEOFLOW_SERVER_URL still works). The CLI reads no other variable for it: a pipeline that exports something else (DEXAFLOW_SERVER, for instance) silently targets the default, http://localhost:8080.
  • A CI credential for the control plane, below.

Credentials for the control plane

A CI job authenticates with a deploy token: a long-lived API token that registers DAG versions and nothing else. Store it as a CI secret named DEXAFLOW_TOKEN; deploy, push, rollback and the other commands read that variable (the pre-rename LEOFLOW_TOKEN still works).

  1. Once, as an admin, create a service user for CI with the built-in deployer role. It reads DAGs and their versions, registers versions and sets the current version (rollback), and nothing else: it cannot delete or pause a DAG, trigger a run, or touch connections, variables or users.

    dexaflow auth create-user --email ci-deploy@example.com --password-stdin \
      --role deployer < password.txt
    
  2. Create a deploy token for that user. Sign in as the service user (the command signs in with --username and the password, or uses the session dexaflow auth login saved), name the token after the pipeline that will hold it, and choose its lifetime:

    dexaflow auth create-token --server https://pro.example.com \
      --username ci-deploy@example.com --password-stdin \
      --scope deploy --name github-my-dags --ttl 90d < password.txt
    

    The token (it starts with dxf_) is printed once, alone on stdout; the name, id, scopes and expiry go to stderr. Store it as the DEXAFLOW_TOKEN secret of the pipeline. The server keeps only its SHA-256 hash, so a lost token cannot be shown again: create a new one.

  3. Set DEXAFLOW_SERVER_URL and DEXAFLOW_TOKEN in the job and run dexaflow deploy, as in the examples below.

What a deploy token can do, and how it ends:

  • Scope. --scope deploy carries dexaflow:deploy: the token reads DAGs, DAG details and versions, registers versions and sets the current version, and is refused everything else, whatever its owner’s roles: no variables, connections, logs, XComs, runs, users or event log. It never does more than its owner’s roles allow either, and the owner’s roles are read again on every request. --scope read makes a token that reads everything its owner can read, so do not give one to CI. deploy --trigger needs to trigger a run, which a deploy token cannot do.
  • Lifetime. --ttl takes days (90d) or a duration (12h), from one hour to 365 days; the default is 90 days. An expired token answers 401.
  • Listing. dexaflow auth tokens list shows your tokens with their id, name, scopes, expiry and when each was last used (recorded at most once every five minutes). An admin sees every token of the tenant.
  • Revoking. dexaflow auth tokens revoke <id> stops a token on its next request. Deactivating the owner stops all of the owner’s tokens.
  • Limits. A token cannot create, list or revoke tokens. A user holds at most 50 live tokens, and token names are unique among a user’s live tokens: 1 to 64 letters, digits, dots, dashes or underscores. Once a token has expired, a new token can take its name: the create retires the expired token and records it in the audit log as api_token.retire_expired, with the old token’s id, when it expired and the id of the token that took its name, so it is not mistaken for a manual revoke (api_token.revoke). The token is sent in the Authorization header only; it never works as a browser session cookie.

Creating, listing and revoking tokens is also available through the API (POST, GET /api/v2/apiTokens and DELETE /api/v2/apiTokens/{id}). The UI does not list tokens yet.

Python on the runner

The dexaflow compile step (which deploy runs) needs Python 3.11, 3.12, or 3.13 on PATH as python3 to parse dag.py. Bring your own Python on the runner; the managed CPython that dexaflow setup can download is meant for a developer laptop, not for build pipelines, where it adds ~50 MB to every run and bypasses your runner’s pin/caching.

The recommended path on each runner type:

RunnerRecipe
GitHub ActionsAdd actions/setup-python@v5 with python-version: '3.12' before installing dexaflow. Cached automatically.
GitLab CIRun the job on python:3.12-slim (the job dexaflow ci init gitlab writes), or in the dexaflow-cli image. A job that needs Docker in Docker runs on docker:27 and installs python3 with apk add python3.
Argo CD, Flux, TektonRun the job in the dexaflow-cli image: it ships Python 3.13, git and uv next to the CLI.
Cloud BuildRun the step on an image with Python 3.11 or later: the generated step installs Debian’s python3 on gcr.io/google.com/cloudsdktool/cloud-sdk:slim, which also has the gcloud session the CLI reads for Artifact Registry. gcr.io/cloud-builders/docker is Ubuntu 20.04, whose python3 is 3.8.
CodeBuildSelect Python 3.11 or later under runtime-versions in the buildspec.
Self-hosted runnersPin Python via your image baseline (apt install python3.12 or pyenv) and version-lock in your runner provisioning.
Generic Docker-in-DockerBase your build container on python:3.12-slim (gives you Python + a Debian userland for the docker build shell).

Older Python (≤3.10) fails the compile cleanly: dexaflow compile errors out with the version requirement, not a confusing traceback.

No dexaflow setup in CI

dexaflow compile and dexaflow deploy extract the parser bundled in the binary into ~/.dexaflow/pysrc/ on their own the first time they run (and again after a binary upgrade), so a runner needs no setup step. Install the binary with DEXAFLOW_NO_SETUP=1: dexaflow setup would also prepare Lite (credentials, datastore) and may download a managed Python, none of which a pipeline uses.

Caching ~/.dexaflow/pysrc/ between runs is optional; the extraction takes a few seconds.

Installing the CLI in a pipeline

A pipeline pins the CLI it runs, so a release does not change what the next run deploys. From v0.5.4 on, the release ships two ways to do that without piping install.sh into a shell: a GitHub Action, and a container image for every system that runs a job in a container.

The setup-dexaflow action

      - uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<VERSION>
        with:
          version: <VERSION>
          server-url: https://pro.example.com   # optional

The action installs the release named by version for the runner’s OS and architecture (Linux or macOS, amd64 or arm64), and puts dexaflow on PATH for the rest of the job. On every run it installs cosign v2.4.3 (and uses that one, not a cosign already on the runner), verifies the keyless signature of the release’s checksums.txt against the release workflow at the v-prefixed tag, and checks the archive against checksums.txt; only then is the binary extracted. The release files (checksums.txt, its signature and certificate, the archive) are cached with actions/cache per version, platform and verify-signature setting. A cache hit saves the downloads only: the cached files are verified again like downloaded ones.

InputDefaultWhat it does
versionthe ref of the action, only when it is a release tagThe release to install, vX.Y.Z or vX.Y.Z-rc.N, any release from v0.5.0 on. Set it: with a commit SHA, a branch or a local path the action fails without it, and latest is refused.
server-urlnoneExported as DEXAFLOW_SERVER_URL for the later steps.
verify-signaturetruefalse skips the signature check and the cosign install (the checksum is still checked), for a runner that cannot reach Sigstore.
OutputValue
versionThe installed tag (v0.5.4).
pathThe absolute path of the dexaflow binary.
cache-hittrue when the release files came from the cache (they were verified all the same).

To pin the action by commit, as for any third-party action: uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<sha> # v0.5.4 with version: v0.5.4.

The action installs only the CLI. compile still needs a Python 3.11 to 3.13 on PATH (Python on the runner), which GitHub’s hosted runners have; actions/setup-python pins one.

The dexaflow-cli image

ghcr.io/dexadata/dexaflow-cli:<VERSION> (also tagged without the v) is the CLI of that release for linux/amd64 and linux/arm64, with what dexaflow deploy runs next to it: Python 3.13 for compile, git for the version label and --changed-since, uv for the daemonless dependency layer, and the CA certificates. It has no Docker CLI: a deploy from it takes the skip, code-layer, dependency-layer or remote builder, so a DAG whose image needs a real build (a package with no wheel, system_packages, its own Dockerfile) needs a remote builder or a job with a daemon (the GitLab recipe under Examples). The Cloud Build remote builder runs gcloud, which the image does not carry.

The entrypoint is dexaflow, and the image runs as UID 65532 with HOME=/home/dexaflow. It is signed like the other images; the identity names the v-prefixed tag, the ref the release workflow ran at:

cosign verify ghcr.io/dexadata/dexaflow-cli:<VERSION> \
  --certificate-identity "https://github.com/dexadata/dexaflow/.github/workflows/release.yaml@refs/tags/<VERSION>" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

The CLI reads registry credentials from the docker config (Registry credentials), so a job writes one from its CI variables. In GitLab CI, with no Docker daemon and no privileged runner:

deploy_dags:
  image:
    name: ghcr.io/dexadata/dexaflow-cli:<VERSION>
    entrypoint: [""]                   # GitLab runs the script in a shell
  variables:
    DEXAFLOW_SERVER_URL: https://pro.example.com
    GIT_DEPTH: "0"                     # git describe needs history and tags
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - mkdir -p "$HOME/.docker"
    - printf '{"auths":{"%s":{"auth":"%s"}}}' "$CI_REGISTRY" "$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 -w0)" > "$HOME/.docker/config.json"
    - dexaflow deploy --all --yes      # DEXAFLOW_TOKEN is a masked CI/CD variable

The job writes the compiled artifacts under the checkout, which GitLab’s Docker executor leaves writable for any user. The image trusts every checkout for git (safe.directory), since the CI system clones it as another user.

As an Argo CD hook, the image checks a target against the repository after each sync; --dry-run exits 1 when something would change, which fails the hook and marks the sync. Drop --dry-run and add --yes to make the target match instead (Syncing a target with the workspace):

apiVersion: batch/v1
kind: Job
metadata:
  generateName: dexaflow-sync-
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      securityContext: { runAsNonRoot: true }
      containers:
        - name: sync
          image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
          command: ["sh", "-c"]
          args:
            - git clone https://github.com/your-org/dags /tmp/dags &&
              dexaflow sync /tmp/dags --dry-run --prune
          env:
            - name: DEXAFLOW_SERVER_URL
              value: https://pro.example.com
            - name: DEXAFLOW_TOKEN
              valueFrom: { secretKeyRef: { name: dexaflow-deploy-token, key: token } }

Flux has no hooks, and it does not run a finished Job again while its spec is unchanged. Run the same container from a Kubernetes CronJob, or from the pipeline that pushes to the repository.

Writing the pipeline: dexaflow ci init

dexaflow ci init <system> writes a working deploy pipeline for the workspace around the current folder, at the top of its git repository, where the CI system reads it:

SystemFiles written
github.github/workflows/dexaflow-deploy.yaml and ci/dexaflow-deploy.sh
gitlab.gitlab-ci.yml and ci/dexaflow-deploy.sh
cloudbuildcloudbuild.yaml and ci/dexaflow-deploy.sh
genericci/dexaflow-deploy.sh alone, to run from any other CI system
dexaflow ci init github
wrote .github/workflows/dexaflow-deploy.yaml
wrote ci/dexaflow-deploy.sh

Next:
  1. create a deploy token, and store it as the CI secret DEXAFLOW_TOKEN:
       dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d
  2. set DEXAFLOW_SERVER_URL in the CI system to the control plane URL
  3. commit the files and push to main

Every pipeline runs ci/dexaflow-deploy.sh, which holds the deploy itself, so the logic is the same on every system. On each push to the branch (--branch, main by default) it:

  1. installs the CLI at the version that wrote it, with the install.sh of that release tag, which checks the archive against the release’s checksums and, when cosign is on the runner, requires its signature. Pass --cli-version vX.Y.Z to pin another release, v0.5.0 or later (a CLI built from source must); DEXAFLOW_CLI_VERSION overrides it in the job. On GitHub the step named Install dexaflow uses the setup-dexaflow action of the same release instead, pinned by the commit of its tag, so the workflow needs v0.5.4 or later. The commit is this CLI’s own, or for another --cli-version the one git ls-remote finds for the tag on GitHub;
  2. installs uv when the runner has none, from PyPI with --require-hashes against the wheel hashes the script carries, so dependency images are built from wheels without a container daemon;
  3. reads the control plane from DEXAFLOW_SERVER_URL, or from the workspace’s target: with --target <name>, or a default_target in dexaflow.workspace.yaml, the script exports DEXAFLOW_TARGET and the pipeline needs no URL (a target of your own config file is not in the repository, so it is not used). The deploy token comes from the CI secret DEXAFLOW_TOKEN (Credentials for the control plane). The pre-rename LEOFLOW_TOKEN, LEOFLOW_SERVER_URL and LEOFLOW_TARGET still work;
  4. runs dexaflow deploy --all --changed-since <base> --yes --output json, where the base is the commit of the last successful deploy on GitHub, and on GitLab with an API token, or the commit before the push otherwise (which commit, and why), or dexaflow deploy --all --yes --output json when there is none (a first run, a new branch, a force push, a source without .git);
  5. writes a summary of the JSON report: the job summary on GitHub ($GITHUB_STEP_SUMMARY), the dexaflow-deploy.md and dexaflow-deploy.json artifacts on GitLab, the build log elsewhere. Set DEXAFLOW_SUMMARY_FILE and DEXAFLOW_REPORT_FILE (absolute paths) to put them somewhere else. The job fails when a DAG fails to deploy.

The registry login is the CI system’s own: GITHUB_TOKEN for ghcr.io, workload identity for Artifact Registry and an IAM role for ECR on GitHub, the job token ($CI_REGISTRY_USER) on GitLab, and the build’s service account on Cloud Build. Another registry gets a login from the secrets DEXAFLOW_REGISTRY_USERNAME and DEXAFLOW_REGISTRY_PASSWORD. The comments at the top of each file list what to set up. The registries are the ones the workspace’s projects push to (registry.url, the target’s registry, the workspace defaults); with none, the file says where to add your login.

On GitHub the job runs in an environment (--environment, production by default), so DEXAFLOW_TOKEN can be an environment secret and the environment’s protection rules (required reviewers, allowed branches) apply to each deploy; secrets and variables of the repository work too. A manual run (workflow_dispatch) deploys only from the deploy branch. Every action is pinned by commit SHA, with its version in a comment, and the checkout keeps no token in the clone. On GitLab, without --branch, the job follows the project’s default branch ($CI_DEFAULT_BRANCH). The container images of the GitLab job and the Cloud Build step are pinned by digest. Run dexaflow ci init <system> --force after upgrading the CLI to pick up the pins of the new release.

The CLI picks the builder at deploy time, as it does on a laptop, and Docker is the fallback. On GitHub the runner has Docker, so nothing changes in the workflow. On GitLab the job needs no daemon, and runs on python:3.12-slim, unless a project needs a real image build (its own Dockerfile, system_packages, a dependency that is not a requirement by name) and no remote builder is configured: then it runs Docker in Docker, and the file names the projects and why. On Cloud Build, the step installs Docker only in that case too.

An existing file is never overwritten: the command names it and writes nothing. Pass --force to replace it, or --stdout to print the files instead of writing them. A .gitlab-ci.yml holds the project’s other jobs, so merge the dexaflow-deploy job into it by hand. After upgrading the CLI, run dexaflow ci init <system> --force to pin the new version.

Examples: what dexaflow ci init writes

These are the files dexaflow ci init writes for a workspace whose projects push to the registry each system logs in to natively (ghcr.io on GitHub, registry.gitlab.com on GitLab, Artifact Registry on Cloud Build), with vX.Y.Z in place of the CLI version. The test suite checks that they match the command’s output.

# Deploys the DAGs changed on main since its last successful deploy to Dexaflow.
# Written by `dexaflow ci init github` for dexaflow vX.Y.Z; the deploy
# itself is in ci/dexaflow-deploy.sh.
#
# Set up once, in the repository settings: an environment named production
# (Settings, Environments) holding
#   - the variable DEXAFLOW_SERVER_URL: the control plane URL;
#   - the secret DEXAFLOW_TOKEN: a deploy token, created with
#     `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
# Repository-level variables and secrets of the same names work too. Protection
# rules on the environment (required reviewers, a branch policy) apply to
# every deploy.
#
# Each run deploys what changed since the commit of the last successful run of
# this workflow on main, so a failed or cancelled run is caught up by the
# next one; with no successful run yet, every DAG is deployed.
name: Deploy DAGs to Dexaflow
on:
  push:
    branches: ["main"]
  workflow_dispatch:
concurrency:
  group: dexaflow-deploy
  cancel-in-progress: false
permissions:
  contents: read
  actions: read # gh run list: the last successful deploy
  packages: write
jobs:
  deploy:
    # A manual run from another branch deploys nothing.
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    env:
      DEXAFLOW_SERVER_URL: ${{ vars.DEXAFLOW_SERVER_URL }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0 # --changed-since and the version label need the history
          persist-credentials: false
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: "3.12"
      # Installs the pinned CLI, after checking its signature and checksum.
      - name: Install dexaflow
        uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<sha> # vX.Y.Z
        with:
          version: vX.Y.Z
      - name: Log in to ghcr.io
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Find the commit of the last successful deploy
        id: base
        env:
          GH_TOKEN: ${{ github.token }}
          BRANCH: main
        run: |
          sha="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow dexaflow-deploy.yaml --branch "$BRANCH" \
            --status success --limit 1 --json headSha --jq '.[0].headSha // ""')" || sha=""
          echo "sha=$sha" >>"$GITHUB_OUTPUT"
      # The CLI picks the builder: an image already in the registry, the DAG
      # code as a layer on the dependency image, dependencies from wheels with
      # uv, the remote builder when one is configured, and the runner's Docker
      # only when none of these applies.
      - name: Deploy the DAGs changed since then
        env:
          DEXAFLOW_TOKEN: ${{ secrets.DEXAFLOW_TOKEN }}
          DEXAFLOW_BASE_REF: ${{ steps.base.outputs.sha }}
        run: sh ci/dexaflow-deploy.sh

setup-dexaflow installs the release named by version, checks its signature and checksum, and caches it per version.

GHCR refuses image names with upper-case letters, so keep registry.url and registry.image_name lower case.

Do not add docker/setup-buildx-action to this job. It makes a docker-container builder the default, whose builds stay in the builder’s cache instead of the local image store; the CLI runs a plain docker build without --load when it falls back to Docker, so the docker push that follows finds no image.

For a pull request, --plan says what merging would deploy. Pass the base branch through env: rather than inline in run::

      - name: What would this pull request deploy
        env:
          BASE_REF: ${{ github.base_ref }}
        run: dexaflow deploy --all --changed-since "origin/$BASE_REF" --plan
# Deploys the DAGs a push to the default branch changed to Dexaflow.
# Written by `dexaflow ci init gitlab` for dexaflow vX.Y.Z; the deploy
# itself is in ci/dexaflow-deploy.sh. To add it to an existing .gitlab-ci.yml, copy
# the dexaflow-deploy job into that file by hand.
#
# Set up once, in Settings, CI/CD, Variables:
#   - DEXAFLOW_SERVER_URL: the control plane URL;
#   - DEXAFLOW_TOKEN, masked and protected: a deploy token, created with
#     `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
#   - DEXAFLOW_GITLAB_API_TOKEN, masked and protected (recommended): a project
#     access token with the read_api scope, see below.
# The job pushes to the project's container registry with the job token
# ($CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD).
#
# What a run deploys: with DEXAFLOW_GITLAB_API_TOKEN, what changed since the
# commit of the last successful push pipeline on the branch, so a failed or
# cancelled pipeline is caught up by the next one. The job token cannot read
# the pipelines API, so without that variable a run deploys what changed since
# the previous commit of its push ($CI_COMMIT_BEFORE_SHA), and the changes of a
# failed, cancelled or skipped pipeline wait for the next change to the same
# DAG: rerun that pipeline, or run one by hand (Build, Pipelines, Run
# pipeline), which deploys every DAG. Turn off "Auto-cancel redundant
# pipelines" for the branch, and set the process mode of the resource group
# dexaflow-deploy to oldest_first (PUT /projects/:id/resource_groups/dexaflow-deploy
# with process_mode=oldest_first), so pipelines deploy in push order.
#
# No container daemon is needed: the CLI pushes the DAG code as a layer on the
# dependency image, and builds that image from wheels with uv.
dexaflow-deploy:
  image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
  resource_group: dexaflow-deploy
  variables:
    GIT_DEPTH: "0" # --changed-since and the version label need the history
    DEXAFLOW_SUMMARY_FILE: $CI_PROJECT_DIR/dexaflow-deploy.md
    DEXAFLOW_REPORT_FILE: $CI_PROJECT_DIR/dexaflow-deploy.json
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
  before_script:
    - apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl ca-certificates >/dev/null
  script:
    - |
      mkdir -p "$HOME/.docker"
      b64() { printf '%s:%s' "$1" "$2" | base64 | tr -d '\n'; }
      printf '{"auths":{"%s":{"auth":"%s"}}}\n' \
        "$CI_REGISTRY" "$(b64 "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD")" \
        >"$HOME/.docker/config.json"
    - |
      DEXAFLOW_BASE_REF="$CI_COMMIT_BEFORE_SHA"
      if [ -n "${DEXAFLOW_GITLAB_API_TOKEN:-}" ]; then
        DEXAFLOW_BASE_REF="$(curl -fsS --header "PRIVATE-TOKEN: $DEXAFLOW_GITLAB_API_TOKEN" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/pipelines?ref=$CI_COMMIT_BRANCH&source=push&status=success&per_page=1" |
          python3 -c 'import json, sys; p = json.load(sys.stdin); print(p[0]["sha"] if p else "")')" || DEXAFLOW_BASE_REF=""
      fi
      export DEXAFLOW_BASE_REF
    - sh ci/dexaflow-deploy.sh
  artifacts:
    when: always
    paths: [dexaflow-deploy.md, dexaflow-deploy.json]
    expire_in: 30 days

When a project needs a real image build and no remote builder is configured, the job runs Docker in Docker instead: image: docker:27 and services: [docker:27-dind] (by digest), the TLS variables (DOCKER_HOST, DOCKER_TLS_CERTDIR) and apk add --no-cache python3 git curl. That image has no pip, so uv is not installed and the daemon builds the dependency images too. docker:dind runs only on a runner configured with privileged = true (and, for the TLS variables, volumes = ["/certs/client"]) in its config.toml; GitLab’s shared SaaS runners are. registry.url in dexaflow.yaml or the workspace defaults is your project’s $CI_REGISTRY_IMAGE, written out (for example registry.gitlab.com/your-group/my-dags).

Build, push and register on Cloud Build; the DAG image runs as task pods on your cluster. The build’s service account pushes to Artifact Registry in the same project through the gcloud session the CLI reads, so registry.url is us-central1-docker.pkg.dev/<project>/dags. The deploy token lives in Secret Manager as dexaflow-deploy-token and reaches the step as DEXAFLOW_TOKEN.

# Deploys the DAGs of this workspace to Dexaflow on Cloud Build.
# Written by `dexaflow ci init cloudbuild` for dexaflow vX.Y.Z; the
# deploy itself is in ci/dexaflow-deploy.sh.
#
# Set up once:
#   - a Secret Manager secret dexaflow-deploy-token holding a deploy token,
#     created with `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`,
#     readable by the build's service account;
#   - the substitution _DEXAFLOW_SERVER_URL: the control plane URL;
#   - a trigger on pushes to main, for example
#     gcloud builds triggers create github --repo-owner=<owner> --repo-name=<repo> --branch-pattern='^main$' --build-config=cloudbuild.yaml \
#       --service-account=projects/<project>/serviceAccounts/<builder>@<project>.iam.gserviceaccount.com
# The step signs in to Artifact Registry with the build's service account
# (the gcloud session the CLI reads), which needs roles/artifactregistry.writer.
# A source uploaded by `gcloud builds submit` has no .git, so every DAG is
# deployed; DAGs whose content is already the current version register
# nothing. When the source is a git clone, the step fetches its full history
# and tags, and _DEXAFLOW_BASE_REF deploys only what changed since a commit.
steps:
  - id: deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim@sha256:ffd36b9b80bb08b2dea60b58ca2b60dfa3a40a3c00f3e5732d6d113f4e7ffa45
    entrypoint: bash
    args:
      - -c
      - |
        set -e
        apt-get update -qq
        apt-get install -y -qq --no-install-recommends python3 python3-pip git curl ca-certificates >/dev/null
        if [ -d .git ]; then git fetch --unshallow --tags || true; fi
        sh ci/dexaflow-deploy.sh
    secretEnv: [DEXAFLOW_TOKEN]
    env:
      - DEXAFLOW_SERVER_URL=${_DEXAFLOW_SERVER_URL}
      - DEXAFLOW_BASE_REF=${_DEXAFLOW_BASE_REF}
      - PIP_BREAK_SYSTEM_PACKAGES=1
substitutions:
  _DEXAFLOW_SERVER_URL: https://dexaflow.example.com
  _DEXAFLOW_BASE_REF: ""
availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/dexaflow-deploy-token/versions/latest
      env: DEXAFLOW_TOKEN
options:
  logging: CLOUD_LOGGING_ONLY

gcloud builds submit uploads the source without .git, so the version label starts from dev and the step deploys every DAG. When the source is a git clone, the step fetches its full history and tags first (git fetch --unshallow --tags). The label still carries the hash of each DAG’s content, so a DAG that did not change registers nothing, and one that changed gets a new label.

dexaflow ci init generic writes the script alone. Run it from any CI system with Python 3.11+ and git on the runner (see Python on the runner), after the registry login, with DEXAFLOW_SERVER_URL, DEXAFLOW_TOKEN and DEXAFLOW_BASE_REF set:

export DEXAFLOW_SERVER_URL=https://pro.example.com
export DEXAFLOW_TOKEN="$DEPLOY_TOKEN_FROM_YOUR_SECRET_STORE"   # a deploy token
export DEXAFLOW_BASE_REF="$COMMIT_BEFORE_THIS_PUSH"             # empty deploys every DAG
sh ci/dexaflow-deploy.sh
#!/bin/sh
# Deploys the DAGs of this workspace that changed to Dexaflow.
# Written by `dexaflow ci init` for dexaflow vX.Y.Z; write it again with
# `dexaflow ci init <system> --force` after upgrading the CLI.
#
# Usage: sh ci/dexaflow-deploy.sh [deploy|install]
#   deploy   (the default) install the CLI and uv when missing, then deploy
#   install  only install the pinned CLI
#
# Environment:
#   DEXAFLOW_SERVER_URL    the control plane URL (required)
#   DEXAFLOW_TOKEN         a deploy token (required), created once with
#                          `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
#   DEXAFLOW_BASE_REF      the commit the branch was on before this push: only
#                          the DAGs changed since it are deployed. Empty, all
#                          zeros (a new branch) or missing from the clone
#                          deploys every DAG.
#   DEXAFLOW_SUMMARY_FILE  a file to append a Markdown summary to (default:
#                          $GITHUB_STEP_SUMMARY, else the log)
#   DEXAFLOW_REPORT_FILE   a file to copy the `dexaflow deploy --output json`
#                          report to (optional)
#   DEXAFLOW_CLI_VERSION   the release to install instead of vX.Y.Z (a tag
#                          such as vX.Y.Z, v0.5.0 or later)
# The pre-rename LEOFLOW_TOKEN, LEOFLOW_SERVER_URL and LEOFLOW_TARGET still work.
#
# The CLI is installed by the install.sh of its release tag, which checks the
# archive against the release's checksums.txt, and its cosign signature when
# cosign is on PATH (then required). uv comes from PyPI, checked against the
# wheel hashes below.
set -eu

cli_version="${DEXAFLOW_CLI_VERSION:-vX.Y.Z}"
DEXAFLOW_TOKEN="${DEXAFLOW_TOKEN:-${LEOFLOW_TOKEN:-}}"
DEXAFLOW_SERVER_URL="${DEXAFLOW_SERVER_URL:-${LEOFLOW_SERVER_URL:-}}"
export DEXAFLOW_TOKEN DEXAFLOW_SERVER_URL
uv_version="0.12.24"
workspace_dir="."

die() {
	echo "error: $*" >&2
	exit 1
}

# The version ends up in a URL and in install.sh: only a release tag is taken.
case "$cli_version" in
v[0-9]*.[0-9]*.[0-9]*)
	case "$cli_version" in
	*[!0-9A-Za-z.-]*) die "DEXAFLOW_CLI_VERSION=$cli_version is not a release tag such as vX.Y.Z" ;;
	esac
	;;
*) die "DEXAFLOW_CLI_VERSION=$cli_version is not a release tag such as vX.Y.Z" ;;
esac

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# install_dexaflow installs the pinned CLI with the release's install.sh,
# unless that version is already on PATH.
install_dexaflow() {
	if command -v dexaflow >/dev/null 2>&1 &&
		[ "$(dexaflow version 2>/dev/null | awk '{print $2}')" = "${cli_version#v}" ]; then
		return 0
	fi
	dir="${DEXAFLOW_INSTALL_DIR:-$HOME/.dexaflow/bin}"
	url="https://raw.githubusercontent.com/dexadata/dexaflow/$cli_version/install.sh"
	if command -v curl >/dev/null 2>&1; then
		curl -fsSL "$url" -o "$tmp/install.sh"
	else
		wget -qO "$tmp/install.sh" "$url"
	fi
	if command -v cosign >/dev/null 2>&1; then
		DEXAFLOW_INSTALL_REQUIRE_SIGNATURE=1
		export DEXAFLOW_INSTALL_REQUIRE_SIGNATURE
	fi
	DEXAFLOW_VERSION="$cli_version" DEXAFLOW_NO_SETUP=1 DEXAFLOW_NO_PATH=1 \
		DEXAFLOW_INSTALL_DIR="$dir" sh "$tmp/install.sh"
	PATH="$dir:$PATH"
	export PATH
	if [ -n "${GITHUB_PATH:-}" ]; then
		echo "$dir" >>"$GITHUB_PATH"
	fi
}

# install_uv puts uv on PATH, so dependency images are built from wheels
# without a container daemon. Without it the remote builder or the local
# daemon builds them.
install_uv() {
	command -v uv >/dev/null 2>&1 && return 0
	echo "uv==$uv_version --hash=sha256:e47005957dca320272ad68960b591b854b6bf0c3288368efb096d9ccd06b8404 --hash=sha256:94c9ed22c1b5d6861a72639e23c5707bb07a354b67ec0462e9a0169a9665b5fd --hash=sha256:0fc14fabb78c55d140a01125d5cd038afb4fed317eb0c3868f3fba87b8cefe16 --hash=sha256:b6fe9cd01f5c576a640f17829cb465899fffb5d6d8dfdd576e38d4f1e40dd438" >"$tmp/uv.txt"
	if python3 -m pip install --quiet --disable-pip-version-check --only-binary :all: \
		--require-hashes -r "$tmp/uv.txt" >/dev/null 2>&1 &&
		command -v uv >/dev/null 2>&1; then
		return 0
	fi
	echo "note: uv could not be installed; dependency images are built by the remote builder or the container daemon" >&2
}

# base_ref prints the commit to compare with, or nothing to deploy every DAG.
base_ref() {
	base="${DEXAFLOW_BASE_REF:-}"
	case "$base" in
	*[!0]*) ;;
	*) return 0 ;;
	esac
	if ! git rev-parse --git-dir >/dev/null 2>&1; then
		echo "note: not a git checkout, so every DAG is deployed" >&2
		return 0
	fi
	if ! git cat-file -e "$base^{commit}" 2>/dev/null; then
		echo "note: $base is not in this clone (a force push, or a shallow clone), so every DAG is deployed" >&2
		return 0
	fi
	echo "$base"
}

# summarize prints a Markdown summary of the deploy report $1.
summarize() {
	python3 - "$1" <<'PY'
import json
import sys

try:
    with open(sys.argv[1]) as f:
        report = json.load(f)
except (OSError, ValueError):
    print("Dexaflow deploy wrote no report; see the job log.")
    sys.exit(0)


def cell(value):
    return str(value or "").replace("|", "\\|").replace("\n", " ")


where = report.get("server") or ""
if report.get("target"):
    where += " (target %s)" % report["target"]
print("### Dexaflow deploy to %s" % cell(where))
print()
dags = report.get("dags") or []
if dags:
    print("| DAG | Status | Version | Image | Note |")
    print("| --- | --- | --- | --- | --- |")
    for d in dags:
        note = d.get("error") or d.get("reason") or d.get("next")
        print("| %s | %s | %s | %s | %s |" % tuple(cell(v) for v in (
            d.get("dag_id"), d.get("status"), d.get("version"), d.get("image"), note)))
else:
    print("No DAG changed, so nothing was deployed.")
for p in report.get("removed") or []:
    print()
    print("- %s (%s) was removed and is still registered. If its DAG should go, remove it with "
          "`dexaflow dags delete %s --deregister`." % (cell(p.get("path")), cell(p.get("dag_id")), cell(p.get("dag_id"))))
for p in report.get("moved") or []:
    print()
    print("- %s moved from %s to %s." % (cell(p.get("dag_id")), cell(p.get("from")), cell(p.get("to"))))
PY
}

deploy() {
	[ -n "${DEXAFLOW_TOKEN:-}" ] ||
		die "DEXAFLOW_TOKEN is not set: store a deploy token as a CI secret (dexaflow auth create-token --scope deploy)"
	[ -n "${DEXAFLOW_SERVER_URL:-}" ] ||
		die "DEXAFLOW_SERVER_URL is not set: set it to the control plane URL"
	base="$(base_ref)"
	report="$tmp/report.json"
	status=0
	if [ -n "$base" ]; then
		dexaflow deploy --all --changed-since "$base" --yes --output json >"$report" || status=$?
	else
		dexaflow deploy --all --yes --output json >"$report" || status=$?
	fi
	summarize "$report" >"$tmp/summary.md" || true
	out="${DEXAFLOW_SUMMARY_FILE:-${GITHUB_STEP_SUMMARY:-}}"
	if [ -n "$out" ]; then
		cat "$tmp/summary.md" >>"$out"
	else
		cat "$tmp/summary.md"
	fi
	if [ -n "${DEXAFLOW_REPORT_FILE:-}" ]; then
		cp "$report" "$DEXAFLOW_REPORT_FILE"
	fi
	return "$status"
}

cd "$(dirname "$0")/.."
cd "$workspace_dir"
case "${1:-deploy}" in
install) install_dexaflow ;;
deploy)
	install_dexaflow
	install_uv
	deploy
	;;
*) die "usage: sh ci/dexaflow-deploy.sh [deploy|install]" ;;
esac

Split into a build stage and a register stage, the same pipeline registers the image tag rather than its digest (see the warning at the top):

dexaflow compile dags/my_pipeline --build --push -o dag.json   # image from registry: in dexaflow.yaml
dexaflow push dag.json

Control-plane deployment (Helm chart, in validation)

Deploying the control plane itself (Helm chart, published dexaflow-server/ dexaflow-migrate images, TLS on the agent channel, keyless cloud auth) is the Pro track. One command installs the chart with auto-generated TLS and no cert-manager — from its published OCI artifact (helm install dexaflow oci://ghcr.io/dexadata/charts/dexaflow --version <VERSION>), or from source on main for the bleeding edge (helm install lf ./helm/dexaflow …). See Install Pro. The chart is installable today and in validation — see the Helm chart, the reproducible Kubernetes test setup (the deploy/k8s recipe is cloud-portable — it runs unchanged on EKS / GKE / AKS), Operating modes, and the Roadmap. The product proves itself in Lite first.

See also: DAG authoring · Operating modes.