Skip to content

Deployments & rollback

The deploy: block marks an executable job as a deployment to a named environment. The job still runs your real deploy script: / uses: — gocdnext does not ship anything for you. deploy: is a tracking marker: when the job succeeds, gocdnext records that this run shipped version to environment. That record powers the Environments tab — current version per environment, full history, and one-click rollback.

By default this is a tracking layer, not an executor. gocdnext doesn’t invent a deploy mechanism (no built-in kubectl apply, no implicit Helm) — your job already knows how to deploy. The primitive adds the visibility a CD tool owes you: what’s live where, when it changed, and who can roll it back.

The exception is opt-in: register a native deploy target for an environment and gocdnext does execute the deploy for it — driving an ArgoCD sync and watching the Application to Synced + Healthy, server-side with no agent. The deploy: block below is identical either way; the environment having a registered target is what turns it native. Everything else on this page describes the tracking-layer default.

Declaring a deployment

jobs:
ship-prod:
stage: deploy
image: google/cloud-sdk:slim
deploy:
environment: production
version: ${{ needs.build.outputs.image-tag }}
script:
- ./deploy.sh
KeyTypeNotes
environmentstring (required)Target environment name. Lazy-created on first deploy — no pre-registration, no separate “create environment” step.
versionstring (optional)The version string recorded as deployed. References allowed: ${{ needs.X.outputs.Y }}, ${{ CI_* }}, ${CI_*}, and ${{ vars.NAME }} (a pipeline/job variables: entry). Resolved against CI vars + those variables only — never secrets (the version is persisted and shown in the UI); a vars. ref binds strictly to variables: so it can never resolve to a secret. Bare ${{ NAME }} and ${{ secrets.NAME }} are rejected. Omitted → defaults to CI_COMMIT_SHORT_SHA.

A deploy job is a normal executable job, so it carries everything a job carries — needs:, artifacts:, id_tokens:, secrets:. The parser rejects deploy: on an approval gate (a gate blocks on a human decision; it doesn’t deploy). The common shape is therefore two jobs — a gate, then the deploy:

jobs:
promote-prod:
stage: deploy
approval:
description: "Promote build to production"
approver_groups: [release-approvers]
ship-prod:
stage: deploy
needs: [promote-prod]
deploy:
environment: production
version: ${{ needs.build.outputs.image-tag }}
script: [./deploy.sh]

The version comes from outputs, not a snapshot

version is resolved at dispatch like any other field, but against CI vars only. The idiomatic source is an upstream job’s output: a build job writes the image tag it produced to $GOCDNEXT_OUTPUT_FILE, the deploy job reads it back as ${{ needs.build.outputs.image-tag }}.

If version can’t resolve — a ${{ CI_TAG_NAME }} on a non-tag run, or an omitted version on a run with no commit short sha — the deploy job fails terminally at dispatch (it does not retry forever against an identical failure). The error names the missing reference, never the value of anything next to it.

Composing a version from a variable (vars.)

A common shape is a pipeline variables: entry (an SDK/app version) that both builds the image tag and labels the deployment. Reference it directly with the explicit vars. namespace — no throwaway job to “launder” it through outputs:

variables:
APP_VERSION: "10.1.19"
jobs:
deploy:
stage: production
uses: ghcr.io/you/argocd@v1
with: { ... }
deploy:
environment: production
version: ${{ vars.APP_VERSION }}-${CI_COMMIT_SHORT_SHA}

${{ vars.NAME }} resolves strictly against the variables: map, so it can never resolve to a secret even if one shares the name — which is exactly why it is allowed in this persisted, UI-shown field while bare ${{ NAME }} (ambiguous) and ${{ secrets.NAME }} (a secret) are rejected. See the substitution grammar.

Environments and revisions

An environment is just a (project, name) pair, created the first time a job deploys to it. It holds no config — it’s a label the history hangs off.

Every dispatch of a deploy job writes a deployment revision:

  • created in_progress between assignment and dispatch,
  • finalized to success or failed by the job’s terminal result — a deploy’s outcome IS its job’s outcome, no separate health check,
  • keyed by (job_run_id, attempt) so a single-job rerun or retry records a distinct revision rather than mutating the prior one.

The Environments tab shows, per environment, the current (latest success) version, a deploy count, and the revision history — a keyset-paginated timeline that gets its own page once it outgrows the card. Via the API:

MethodPathReturns
GET/api/v1/projects/{slug}/environmentsenvironments with their current version + deploy count
GET/api/v1/projects/{slug}/environments/{envID}/deploymentsrevision history (keyset-paginated via cursor, with the timeline total)
POST/api/v1/projects/{slug}/environments/{envID}/rollbackroll back (see below)
POST/api/v1/projects/{slug}/environments/{envID}/redeployredeploy the current version (see below)
PUT/DELETE/api/v1/projects/{slug}/environment-freezes/{name}freeze / unfreeze the environment (maintainer)

One-click rollback

Each successful deploy in an environment’s history offers a roll-back action — as long as its run still exists. Rollback does not snapshot manifests or override a version. It re-runs the deploy job of the chosen past run.

That works because of one invariant: a finished run’s job outputs are immutable. Re-running that run’s deploy job re-resolves ${{ needs.*.outputs.* }} to the same values it saw originally, so ./deploy.sh ships the same version again. The old version “freezes” for free — no version field to carry, no manifest to store. The new revision is flagged is_rollback = true so the history distinguishes it from a forward deploy.

Because rollback replays a real job, it goes through the same dispatch path — same agent, same secrets, same id_tokens:. If the source run has since been pruned (its outputs are gone), the roll-back action isn’t offered: there’s nothing immutable left to replay.

A rollback is a deploy, so it is refused with 409 while the environment is under a change-freeze.

Redeploy the current version

Alongside rollback, an environment offers Redeploy current version — re-run the deploy job behind the environment’s current successful revision. It’s the “ship the same thing again” button: a wedged rollout, a flaked post-deploy step, a cluster that drifted out from under you.

The server resolves current at request time, so a stale tab can’t ask to redeploy an older revision while calling it current. Like rollback it replays a real job — immutable outputs re-resolve to the same version — is refused with 409 under a change-freeze, and is unavailable once the current revision’s run has been pruned. Unlike rollback it is not flagged is_rollback: a redeploy is a normal forward deploy, so it doesn’t distort rollback-based change-failure signals.

What this is not

  • Not an executorunless the environment has a native deploy target. By default there’s no environment-level config, no built-in apply step, no drift detection; your script: owns the mechanism. A native target is the deliberate opt-in that makes gocdnext drive the deploy.
  • Not a gate. Gating a deploy is the job of an approval on a separate job upstream of the deploy. To stop all promotion to an environment for a while — regardless of who would approve — use an environment change-freeze.
  • Not a health monitor. Success tracks the deploy job. If your rollout needs a post-deploy smoke test, make it part of the script: (or a downstream job) so its failure fails the deploy.

See also