Skip to content
RunDeploy

Promote and sync

Digests move through the environment graph declared in deploy.yaml: pinned into the first environment, copied verbatim into the next, reviewed and applied at the manual gate. Every command here edits the GitOps repository or asks ArgoCD; none of them touches a cluster directly, and kubectl is never invoked.

Terminal window
intropy deploy pin order-extractor 1.4.2 --env dev # put a release into an environment
intropy deploy promote order-extractor --from dev --to staging
intropy deploy diff order-extractor --env prod # review the pending change
intropy deploy status order-extractor # the ladder, side by side
intropy deploy sync order-extractor --env prod # spend the manual gate

deploy pin writes a component’s image digests into one environment’s kustomize overlay, commits only that overlay’s kustomization.yaml with machine-readable trailers recording what was deployed, and pushes to the repository’s default branch.

Where the digests come from is the choice:

  • Without a version: the current commit. Run it inside the component’s source repository; HEAD must be pushed and the tree clean under the component’s sourcePaths (--allow-dirty waives it). The CLI resolves the digest CI published for that commit, failing if the pipeline has not published it yet; -w/--watch polls the registry until it appears.
  • With a version: a release. The manifest already recorded the digests, so nothing reads the source repository and the command works from any directory.

--plan renders the overlay before and after, prints the diff, and writes nothing. When the target environment declares promotesFrom, the plan also reports whether the digests it is about to pin are what those environments already run. That is reporting, not a gate: refusing is promotion policy, and belongs to promote.

If someone else pushed first, the commit is rebased onto their work and retried, up to five times with jittered backoff. A rebase conflict means someone deployed the same component to the same environment in the same moment: that fails and pushes nothing, because auto-resolving would silently discard a deployment someone believes succeeded.

After pushing, the command waits for ArgoCD to apply the new revision and become healthy (--no-wait skips it, --timeout bounds it, default 5m). A sync: manual environment never waits: the commit records intent, and the command prints the deploy diff and deploy sync follow-up instead.

Re-running once a digest is already pinned prints already at … and exits 0 without creating an empty commit.

A pin resolves a digest; a promotion resolves nothing:

Terminal window
intropy deploy promote order-extractor --from staging --to prod

It reads the digests --from currently pins and writes those exact values into --to. It does not look a version up in the registry, so a release tag that has since been moved, or a registry that answers differently than it did an hour ago, cannot substitute different bits for the ones staging tested. The source’s deploy.internal/source-commit and deploy.internal/release annotations are copied too: which commit produced the bits does not change because the bits moved environments.

Everything after the digests are chosen is the pin path: the same --plan, the same rebase-and-retry push, the same ArgoCD wait, the same stop-and-print at a manual environment.

Two deploy.yaml fields that a pin only reports on are enforced here, and both refuse before anything is written:

  • The edge must be declared. If prod promotes from staging, then --from dev --to prod is refused and the error names the legal sources:

    error: prod does not promote from dev.
    deploy.yaml allows: staging
  • requireSourceHealthy must hold. The source’s ArgoCD application must be Synced and Healthyand at the revision its current digests were pinned by. That second half is the substance: an auto-syncing source can advance between its overlay being read and its health being asked about, so a Healthy answer on its own might describe a later deployment of entirely different bits.

A promotion also refuses a source that pins a tag rather than a digest, or pins nothing at all: there is no fixed set of bits to copy, and inventing one is the thing promotion exists to avoid. And unlike a pin (where an unreachable ArgoCD after the push is a warning, because the commit is the deployment), an unreachable ArgoCD is fatal here when health is a precondition. “I could not check” is not “it is fine”; there is no override flag, only clearing requireSourceHealthy in deploy.yaml.

An environment with sync: manual is applied by ArgoCD on request, not by pushing. A pin or promotion into it records intent and stops; a second person reads what it would do, then applies it.

deploy diff answers “what am I approving?” It renders the environment’s manifests at the revision ArgoCD has applied and at the revision a sync would apply next, and prints the difference between the actual resources, not the one-line image pin. Both sides are commits, so everything between them counts: a base that moved, several deployments that stacked up unapplied, an environment never synced at all. That is what makes it different from --plan, which diffs a hypothetical uncommitted edit for the person writing it.

ArgoCD does the rendering, because the Application is the whole input: spec.source.kustomize overrides and the installation’s kustomize.buildOptions are invisible to a local kustomize build, and at an approval gate a diff that is not what gets applied is worse than no diff. ArgoCD must therefore be reachable: its applied revision is an input, not an observation. The command warns about the cases where the diff alone would mislead: resources the sync would not prune, an application that is not Synced (a kubectl edit is invisible to a git-to-git diff, and applying will revert it), and an ArgoCD already holding the pending commit or a descendant.

A non-empty diff still exits 0: this reports, it does not gate. It prints the full revision sha, and the follow-up command to run.

deploy sync spends the gate. The revision synced is the commit that last changed that environment’s overlay, not the branch head (which could include commits nobody reviewed). Pass --revision with the sha whose diff you read, and the sync is refused if the pending change is a different one:

Terminal window
intropy deploy sync order-extractor --env prod --revision 7c30d81

Nothing is written to git. ArgoCD evaluates the caller through its own RBAC and records who did it. That is why the gate lives there rather than in a forge approval on a YAML edit: the approver inspects the rendered resources in the system that applies them. An application already holding the revision is a no-op, and a denied sync fails with ArgoCD’s own reason.

The last step of a release is checking that the same thing is running everywhere:

Terminal window
intropy deploy status order-extractor
COMPONENT ENV RELEASE DIGEST AGE SYNC HEALTH
order-extractor dev 1.4.2 sha256:ad22d6f2ecbc 2h Synced Healthy
order-extractor staging 1.4.2 sha256:ad22d6f2ecbc 47m Synced Healthy
order-extractor prod 1.4.2 sha256:ad22d6f2ecbc 3m Synced Healthy
all 3 environments run sha256:ad22d6f2ecbc — these are the same bits, promoted rather than rebuilt

The sentence under the table says whether every environment runs the same digest. Rows follow the promotion graph, last row furthest downstream. Release, digest, and age come from the overlays; SYNC and HEALTH come from ArgoCD, and if it cannot be reached those two columns are left empty while everything else still prints. An unreachable cluster says nothing about what the overlays pin. An environment with a committed change waiting on its manual gate is called out with the deploy diff command to review it; for a sync: manual environment that is a normal resting state, not a fault.

Nothing here can fail on one bad environment, and the exit code is always 0: this reports, it does not gate. To gate in CI, read consistent from the machine-readable form:

Terminal window
intropy deploy status order-extractor --output json | jq -e '.consistent'

The JSON also carries every image and the deploy time as an instant rather than a rendered age.

Credentials come from the argocd CLI’s own configuration, so if you have run argocd login nothing more is needed. ARGOCD_SERVER and ARGOCD_AUTH_TOKEN override it: those are argocd’s own variable names, honoured so an existing CI setup works unchanged. Precedence for the server: the --argocd-server flag, then ARGOCD_SERVER, then deploy.yaml’s argocd.server, then argocdServer in the user configuration. deploy.yaml beats the user configuration on purpose: it travels with the repository the overlays live in.

The post-push wait is defined in terms of the pushed revision, not just sync status: polling for Synced/Healthy alone would read the previous revision’s perfectly healthy state on the first poll. A descendant of your commit also counts, since a later deployment landing on top would otherwise make an exact match hang forever. An unreachable ArgoCD or expired token after a push is a warning and exit 0: the commit is the deployment, and being unable to watch it does not undo it.

Full flag detail: intropy deploy.