Skip to content

Native deploys (ArgoCD)

By default, deploy: is a tracking marker: your job runs its own script: / uses: to ship, and gocdnext records what shipped where. A native deploy target flips that for one environment — register it, and gocdnext becomes the executor: it drives the ArgoCD sync and watches the Application to Synced + Healthy itself, server-side, with no agent and no deploy script:.

The two are the same deploy: block. Whether a job is tracking-only or native is decided purely by whether its environment has a registered target — the pipeline YAML doesn’t change.

Two behaviours of one deploy: job

Environment has…Behaviour
no registered targetTracking layer (the default). The job runs your script:/uses: on an agent; a deployment revision is recorded from the job’s outcome.
a native targetNative / server-managed. No agent, no script: — gocdnext issues the ArgoCD sync (in trigger mode) and watches the Application to convergence. The job’s success is the deploy’s convergence.

A job whose environment has no target simply falls through to the normal agent path, so registering a target is a non-breaking opt-in per environment — nothing else in the pipeline moves.

What “native” means

gocdnext runs a server-side deployment provider — a thin client over the ArgoCD API. It observes and syncs, but never reconciles: ArgoCD stays the reconciler and the manifest renderer (helm/kustomize/your GitOps repo). gocdnext does not become a second controller — it asks ArgoCD to sync, then watches the Application’s .status to a verdict.

Scope today: the provider implements sync + watch (observe an Application to Synced + Healthy, or trigger the sync first) and gate-driven Argo Rollouts control — a canary that pauses at an indefinite pause: {} step arms a gocdnext approval gate (approve → promote a step, reject → abort). A git-only write mode remains a future phase in ADR-0001.

Registering a target

Register a target from the maintainer dialog on the Environments card, or via the API below. Both paths are maintainer-gated — list, upsert, and delete all require the maintainer role, because the target reveals which cluster / Application / namespace an environment deploys to. (A target that carries a governing_gate tightens the gate and its rollout routing to admin — see gate-driven rollouts.)

Terminal window
# Upsert the target for one environment (1:1, keyed on environment).
curl -sS -X POST \
-H "Authorization: Bearer $GOCDNEXT_TOKEN" \
-H "Content-Type: application/json" \
https://gocdnext.example.com/api/v1/projects/shop/deploy-targets \
-d '{
"environment": "production",
"cluster": "argocd-hub",
"application": "shop-prod",
"namespace": "argocd",
"sync_mode": "trigger"
}'
FieldRequiredNotes
environmentyesThe gocdnext environment this target deploys — the match key for a deploy: job. Must match ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$.
clusteryesA registered cluster name — the cluster whose API hosts the ArgoCD Application CR (the ArgoCD hub, see below), not the workload’s destination cluster.
applicationyesThe ArgoCD Application name.
namespacenoNamespace holding the Application CR. Defaults to argocd.
sync_modeyestrigger or observe (no default).

provider is always argocd — you don’t send it.

Validation is fail-closed and happens before any write. Beyond the field checks, the server fetches the real Application through the cluster to confirm it: (a) exists and is reachable, (b) the project is authorised for that cluster, and (c) is single-source — multi-source Applications are rejected (422). A registration that passes has already proven the whole read path works.

Terminal window
# List the project's targets
curl -sS -H "Authorization: Bearer $GOCDNEXT_TOKEN" \
https://gocdnext.example.com/api/v1/projects/shop/deploy-targets
# Remove one (frees the environment back to tracking-only)
curl -sS -X DELETE -H "Authorization: Bearer $GOCDNEXT_TOKEN" \
https://gocdnext.example.com/api/v1/projects/shop/deploy-targets/production

Every upsert/delete is audited (deploy_target.set / deploy_target.delete).

Declaring the target in the pipeline

Instead of registering out-of-band, a pipeline can declare its own target:

deploy:
environment: production
version: 1.${{ CI_UPSTREAM_RUN_COUNTER }}.${{ CI_COMMIT_SHORT_SHA }}
target:
cluster: prod-hub # a registered cluster (the ArgoCD hub)
application: shop-prod
namespace: argocd # optional, defaults to argocd
sync_mode: trigger # REQUIRED: trigger | observe

It is applied at dispatch, not at apply: registration needs a live Application fetch, and the automatic (webhook-driven) apply path has no authenticated user to attribute a privileged write to. Apply time still checks the shape and that the cluster exists, so a typo fails fast with a specific message.

sync_mode is required on purpose — defaulting it would let an omission turn gocdnext into the thing that syncs an Application you meant to only observe.

What the pipeline may and may not own

cluster, application, namespace, sync_modeowned by the pipeline
the approval gatenever settable from YAML — it must stay impossible to drop your own gate by editing a file
rollout routing (rollout_aware, rollout_*)preserved, not expressible: a value the file cannot describe is never cleared by it

Consequences worth knowing:

  • A target that carries a governing gate refuses the declaration outright, even when the fields match. Remove deploy.target (the registered target governs) or ask an admin to ungate it.
  • A target with pinned rollout routing refuses a change to the Application’s identity (cluster, namespace or application) — otherwise the deploy would sync one Application while promoting another’s Rollout. Change it via the UI/API. Switching sync_mode is fine: it changes who issues the sync, not which object is deployed, so the pin stays valid.
  • Only one job may declare a target for a given environment, and two declarations must be identical: the target is 1:1 with the environment, so conflicting ones would flip it between runs. The apply fails naming both jobs.
  • Whether self-service is permitted at all is a property of the cluster — see Pipeline-declared deploy targets.

Every write is audited with a pipeline:<id> actor, so “who changed this target?” stays answerable even though no human is in the loop.

trigger vs observe

ModeForWhat gocdnext does
triggermanual-sync ApplicationsIssues the sync itself — patches the Application’s .operation (as user gocdnext, carrying the app’s own syncPolicy.syncOptions such as CreateNamespace=true) — then watches to convergence. This is the mode where a gocdnext gate can precede the sync.
observeauto-sync ApplicationsIssues no sync. An external GitOps writer commits and ArgoCD auto-syncs; gocdnext only watches the Application to Synced + Healthy.

In both modes the deploy succeeds only when the Application reports Synced + Healthy at the revision this run expects (correlated by the full commit SHA ArgoCD reports). If it doesn’t converge within the deadline (15 min default) the deploy fails with a progress-deadline error; a Degraded health is debounced briefly before failing, to ride out a transient blip.

End-to-end examples

Gated deploy in trigger mode (build → approve → native sync)

Register the target once (maintainer):

Terminal window
curl -sS -X POST \
-H "Authorization: Bearer $GOCDNEXT_TOKEN" \
-H "Content-Type: application/json" \
https://gocdnext.example.com/api/v1/projects/shop/deploy-targets \
-d '{"environment":"production","cluster":"argocd-hub","application":"shop-prod","sync_mode":"trigger"}'

Then the pipeline — a gate, then the deploy job:

jobs:
build:
stage: build
image: golang:1.23
script: ["make build"]
promote-prod:
stage: deploy
approval:
description: "Promote to production"
approver_groups: [release-approvers]
ship-prod:
stage: deploy
needs: [promote-prod]
# The body is the FALLBACK — it runs only if `production` has NO
# registered target. With the target above registered, gocdnext
# syncs ArgoCD itself and this `uses:` never runs.
uses: ghcr.io/klinux/gocdnext-plugin-argocd@v1
with:
command: "app sync shop-prod"
deploy:
environment: production
# No `version:` → correlates against THIS run's commit
# (CI_COMMIT_SHA) — the SHA ArgoCD reports as synced.

On this run: build runs on an agent, promote-prod blocks for the approval, and once approved ship-prod is taken over by the server — no agent — which patches the Application’s .operation to sync shop-prod, then watches it to Synced + Healthy at the run’s commit. The gate precedes the sync, which is exactly what trigger mode is for.

Watch-only in observe mode (auto-sync app)

For an Application that ArgoCD already auto-syncs (an external GitOps writer commits the manifests), register the target as observe:

Terminal window
curl -sS -X POST \
-H "Authorization: Bearer $GOCDNEXT_TOKEN" -H "Content-Type: application/json" \
https://gocdnext.example.com/api/v1/projects/shop/deploy-targets \
-d '{"environment":"staging","cluster":"argocd-hub","application":"shop-staging","sync_mode":"observe"}'
jobs:
watch-staging:
stage: deploy
uses: ghcr.io/klinux/gocdnext-plugin-argocd@v1 # fallback only
with:
command: "app wait shop-staging --health"
deploy:
environment: staging

gocdnext issues no sync here — it just watches shop-staging to Synced + Healthy at the run’s commit and finalizes the job on convergence (or fails on the deadline). Use observe when something else owns the sync and you only want gocdnext to gate the pipeline on the real rollout landing.

Centralized ArgoCD: cluster: is the hub, not the destination

This is the part worth being explicit about. In the target, cluster: is the cluster where ArgoCD runs — the one whose Kubernetes API serves the Application CR that gocdnext reads and patches. It is not the cluster the workload lands on.

Where the workload lands is the Application’s own spec.destination, which is entirely ArgoCD’s concern — gocdnext never reads or writes it. So a single centralized ArgoCD managing many destination clusters is the natural fit, and it needs no special handling:

┌────────────────────── ArgoCD hub cluster ──────────────────────┐
gocdnext ────► │ ns: argocd │
(reads + │ Application "shop-staging" → spec.destination: cluster-a │ ──► workload → cluster-a
patches │ Application "shop-prod" → spec.destination: cluster-b │ ──► workload → cluster-b
the CR) │ Application "shop-eu" → spec.destination: cluster-eu │ ──► workload → cluster-eu
└─────────────────────────────────────────────────────────────────┘
TopologyHow to register
Centralized ArgoCD (one hub, many destination clusters)Register the hub once in the cluster registry. Every target points cluster: at that hub, namespace: argocd, and names a different application.
ArgoCD per environment/region (several hubs)Register each hub as its own cluster; each target’s cluster: points at the right hub.

Credentials. The hub cluster is a normal cluster-registry entry — kubeconfig or a scoped token. Because the control plane reaches the hub from outside, in_cluster credentials are rejected for native targets (they’re only valid from inside that cluster’s own pods). The token needs least-privilege RBAC on the Application CRs: get to validate/observe the Application, plus patch when the target uses trigger mode and gocdnext starts the sync:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: argocd
name: gocdnext-deployer
rules:
- apiGroups: ["argoproj.io"]
resources: ["applications"]
verbs: ["get", "patch"]

When a native target is registered or reconciled from pipeline YAML, gocdnext asks Kubernetes for a SelfSubjectAccessReview on these exact Application permissions. If Kubernetes answers allowed=false, the target is rejected before any database write with an error naming the missing verb/resource. gocdnext never creates or widens RBAC itself; the operator still owns the Role/Binding.

For gate-driven rollout control (below), the token that reaches the Rollout’s cluster (the workload’s destination — the same registered cluster as the Application in a co-located hub, or a separate rollout_cluster) additionally needs rollouts and the rollouts/status subresource, in the workload namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: shop # the Rollout's namespace, not argocd
name: gocdnext-rollout-controller
rules:
- apiGroups: ["argoproj.io"]
resources: ["rollouts"]
verbs: ["get", "list", "watch", "patch"]
# Promote (clear pauseConditions) and Abort (set abort) are merge-patches
# to the /status subresource — a distinct RBAC resource.
- apiGroups: ["argoproj.io"]
resources: ["rollouts/status"]
verbs: ["get", "patch", "update"]

Gate-driven canary rollouts

When the Application manages an Argo Rollouts Rollout, a native target can observe and control the canary. Turn on rollout_aware (gocdnext reads the Rollout the Application manages — auto-discovered from .status.resources[], or pin rollout_cluster/rollout_namespace/ rollout_name), and add a governing_gate to make it gated:

{
"environment": "production",
"cluster": "argocd-hub",
"application": "shop-prod",
"rollout_aware": true,
"governing_gate": { "approvers": ["sre@acme"], "required": 2, "description": "prod canary" }
}

A governing_gate is admin-only to set or change, and — once a target is gated — so is the rollout routing (a maintainer must not be able to reroute around a gate they can’t drop). rollout_aware alone, with no gate, is observe-only: gocdnext surfaces canary progress but never promotes or aborts.

The loop, once the deploy is in flight:

  1. The canary advances until it reaches an indefinite pause: {} step (no duration — the human-gate step). Timed pauses, analysis pauses, and blue-green pauses do not arm a gate.
  2. gocdnext arms a gate on the in-flight deploy, pinning the Rollout’s identity and minting a fresh gate_id. The deploy job stays running; its progress deadline is suspended while the gate awaits a human.
  3. The Environments card shows Canary paused · step 3/5 · awaiting approval (1/2) with Approve / Reject. Votes reuse the same quorum / groups / allow-list engine as pipeline approval gates.
  4. Approve (once quorum is met) → gocdnext promotes one step (clears pauseConditions); the controller advances and re-pauses at the next pause: {}, re-arming a fresh gate. Reject → gocdnext aborts (sets .status.abort) — traffic returns to stable. Once the decision lands the deadline resumes, so a stuck controller still fails on the budget rather than hanging.
  5. A Synced + Healthy Application does not finalize success while the canary is mid-rollout — only when it is fully promoted (past all steps, new version stable). Superseding or canceling the run aborts the rollout too.

Under a control-mode read error (the Rollout can’t be observed), gocdnext fails closed: it never promotes on uncertainty and never finalizes on Application health alone — but a reject still aborts (abort is safe; promote is not).

What the operator sees

Targets are registered from the maintainer dialog on the Environments card; observation is visible to viewers, and the rollout controls are maintainer+/admin-gated (below):

  • Environments card — a native row per environment: Native · ArgoCD · app <application> · cluster <cluster> · <sync_mode> (an eye icon for observe, a refresh icon for trigger). A maintainer gets an Add / edit target dialog here (and, for an admin, Remove — a hard-delete that also drops the environment’s deploy history, refused with a 409 while a deploy is in flight). The config detail is maintainer-only.
  • Rollouts dashboard (/projects/{slug}/rollouts) — a live view, per cluster + namespace, of every canary and blue-green Rollout: steps timeline, traffic split, revision strip, and AnalysisRun. When a gate governs a Rollout it surfaces Approve / Reject on that exact Rollout; for a non-gated rollout a maintainer gets direct Promote / Abort (a gated rollout refuses direct control with a 409 — the decision must flow through the audited vote path, never a bypass). A quick-pick lists the project’s configured rollout targets so you never have to guess the pair — remember the Rollout CR lives in the workload namespace, not the argo-rollouts controller namespace (which holds zero Rollouts).
  • Live watch chip — while a native deploy is in flight, a chip shows DeployingSyncing (once the sync is requested) → Degraded <time> if health drops. A rollout-aware deploy shows canary state instead (Rolling out step 3/5, Canary paused, Rollout healthy/aborted). When the canary is running a metric AnalysisRun, an analysis <phase> badge rides alongside (observe-only) — red for Failed/Error, amber for Inconclusive, green for Successful — with the analysis message on hover, so an inconclusive pause reads as “analysis inconclusive: success-rate 0.91 < 0.95”, not a bare “Paused”. Backed by GET /api/v1/projects/{slug}/deploy-watches (viewer-readable, but config fields are maintainer-only).
  • Approval notice — when a canary gate is armed, an amber Canary paused · awaiting approval (N/M) banner appears on the card with a Review and decide link. The decision itself is made on the Rollouts tab (below), where the steps, traffic split and analysis that should justify it are visible; the card reports, it does not act. The link carries the Rollout identity pinned when the gate armed, so it lands on the exact rollout even when the namespace holds several. A viewer sees the notice but no link — the rollouts read is maintainer-gated. The server enforces the approvers allow-list and the gate_id token, so a stale tab voting on a superseded step gets a clear 409.
  • Server logs — the watch loop emits watch_claimed, watch_observed, watch_decision, and watch_finalize; the scheduler logs deploy_native_selected / native deploy dispatched when a job takes the native path (never dispatched to an agent).

Fail-closed posture

Native deploys never fake success. An unreachable hub, an auth error, a revision mismatch, or a non-convergent Application all hold or fail — they never mark an environment healthy on incomplete evidence. A target-resolution error (as opposed to “no target registered”) stops the dispatch and retries next tick rather than silently falling back to an agent run.

What this is not (yet)

  • Not a reconciler. gocdnext asks ArgoCD to sync and watches the result; ArgoCD owns desired state and rendering.
  • Reject is not a Git revert. Rejecting a gate aborts the Rollout — traffic shifts back to the stable ReplicaSet — but .spec.template (the desired version) is unchanged, so a re-sync or a corrected commit rolls forward. gocdnext never rewrites Git.
  • Not a replacement for the argocd plugin. The fire-and-forget plugin remains for bespoke setups; a pipeline opts into native deploy: when it wants convergence + gate coupling.

See also