Continuous Integration vs Continuous Delivery vs Deployment

Teams say “CI/CD” as if it were one thing, but continuous integration (CI), continuous delivery, and continuous deployment are three distinct stages of automation, each building on the guarantees of the one before it. Mixing them up is not just sloppy vocabulary — it changes who is expected to approve a release, what steps run without a human watching, and which secrets a workflow needs access to. This lesson defines each stage precisely and shows how the boundary between them is expressed in real GitHub Actions workflow YAML.

Overview / How it works

Continuous integration means every change is automatically built and tested as soon as it is pushed or opened as a pull request. The goal is to catch integration bugs — two branches that individually work but conflict when combined — within minutes instead of days. CI does not necessarily produce anything meant for production; its job is to keep the main branch in a known-good state.

Continuous delivery builds on CI by guaranteeing that every change which passes the pipeline results in a release-ready artifact: a container image, a package, a build bundle. The team is confident that main is always deployable. What continuous delivery does not do is push that artifact to production automatically — a human still decides when a release happens, typically by approving a deployment gate.

Continuous deployment removes that human gate. Every change that passes the pipeline’s automated checks is deployed to production immediately, with no manual approval step. Because there is no person reviewing the release before customers see it, the safety of continuous deployment depends entirely on the quality of the automation: fast and thorough tests, security scans, health checks after deploy, and an automatic rollback path.

The difference between the three is not about tooling — the same GitHub Actions workflow can express any of them. The difference is where the automation stops and a person is expected to intervene.

Syntax or workflow structure

GitHub Actions maps cleanly onto this spectrum using a small set of building blocks:

  • on: triggers decide when each stage runs — pull_request for CI feedback on proposed changes, push to a protected branch for anything that touches production.
  • jobs: combined with needs: express the pipeline as an explicit chain: test must succeed before build-and-push runs, which must succeed before deploy runs.
  • permissions: should be scoped per job, not left at the repository default. A test job only needs contents: read; a job that pushes a container image also needs packages: write; neither needs deploy credentials.
  • environment: on a job is how GitHub Actions expresses a delivery gate. When an environment has required reviewers configured under Settings > Environments, any job referencing that environment pauses and waits for an approval before it runs — this single setting is what turns a workflow from continuous deployment into continuous delivery, even though the YAML looks almost identical.
  • outputs: pass values like an image digest between jobs, so the deploy job can reference the exact artifact that was tested and built, not just a mutable tag.

Examples

Example 1: Continuous integration only

This workflow runs on every push and pull request, including ones from forks. It has read-only permissions and touches no secrets, so it is safe to run against untrusted contributions.

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
      - run: npm run lint

Expected behavior: Every push and PR gets a pass/fail status check within a few minutes. A branch protection rule requiring this check means nothing merges to main without green tests. Nothing is deployed — this pipeline only tells you the code is correct, not that it has shipped anywhere.

Example 2: Continuous delivery

This workflow extends Example 1: after tests pass on main, it builds a container image, tags it with the commit SHA, and pushes it to GitHub Container Registry. The deploy job targets an environment: production that has required reviewers configured in the repository settings, so it pauses until someone approves it.

name: Build and Publish Image

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    steps:
      - name: Deploy reviewed image by digest
        run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}

Expected behavior: Every merge to main produces a fresh, tested image sitting in the registry, ready to ship. The deploy job appears as “waiting” in the Actions UI until a designated reviewer clicks approve. Note the deploy step references the image by digest (a content hash), not by a mutable tag — the reviewer approves an immutable artifact, not a name that could point somewhere else by the time the job runs.

Example 3: Continuous deployment

This workflow removes the manual gate. The production environment here has no required reviewers — deployment happens automatically as soon as the build succeeds — but it adds a health check after deploy and an automatic rollback if that check fails.

name: Deploy to Production

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
      previous_digest: ${{ steps.previous.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - id: previous
        run: echo \"digest=$(./scripts/current-production-digest.sh)\" >> \"$GITHUB_OUTPUT\"
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    steps:
      - name: Deploy image by digest
        run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}
      - name: Health check
        run: |
          for i in 1 2 3 4 5; do
            if curl -fsS https://${{ secrets.DEPLOY_HOST }}/healthz; then
              exit 0
            fi
            sleep 10
          done
          exit 1
      - name: Roll back on failure
        if: failure()
        run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.previous_digest }}

Expected behavior: Every merge to main is live in production within minutes, with no human in the loop. If the post-deploy health check fails five times in a row, the workflow redeploys the previously known-good digest automatically and the job is marked failed so the team is alerted. This only works safely because tests, the health check, and the rollback path were already trustworthy before the manual gate was removed.

Step by step

  1. A developer opens a pull request. The CI workflow (Example 1) runs on pull_request with read-only permissions and no secrets, so it is safe even for a pull request from a fork.
  2. The pull request merges to main. The push event triggers the full pipeline: tests run again against the merge commit, then build-and-push builds a container image and pushes it to the registry tagged with the commit SHA.
  3. If the team practices continuous delivery, the deploy job pauses on the production environment’s required reviewers. A release manager checks the build, the changelog, and any pending incidents, then approves.
  4. If the team practices continuous deployment, there is no pause: the same job runs immediately, deploys the image by digest, and executes a health check loop.
  5. Regardless of which model is in place, monitoring and alerting outside the workflow keep watching production after the job finishes — that ongoing observability is what lets a team trust continuous deployment enough to remove the manual gate in the first place.

Common Mistakes

Mistake 1: Deploying with credentials on untrusted pull request code

A workflow that checks out a fork’s pull request head using pull_request_target and then runs a deploy step with production secrets lets an attacker who opens a pull request run arbitrary code with your deploy token. This is not a theoretical risk — it is one of the most common real-world GitHub Actions compromises.

on:
  pull_request_target:

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci
      - run: npm run build
      - run: ./scripts/deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}

The fix is to keep the two concerns separated: run tests and builds for pull requests (including forks) on the plain pull_request event with no access to deploy secrets, and only run the job that has deploy credentials on push to main, after the pull request has already been reviewed and merged by a maintainer.

name: CI

on:
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - run: npm test

# A separate workflow triggered only by push to main, after merge,
# runs deploy.sh with production secrets. Fork pull requests never reach it.

Mistake 2: Calling a pipeline “continuous deployment” when it isn’t

A team labels its workflow “Deploy to Production” and tells new hires “we do continuous deployment,” but the actual release only happens after someone pastes an approval into a chat channel outside GitHub. During an incident, whoever is on call assumes production auto-updates on every merge and loses time looking for a deploy that a person is still sitting on. The workflow’s real behavior and its name and mental model disagree.

The fix is to make the gate visible in the same system that runs the pipeline, and name things for what they actually do: use an environment with required reviewers configured in Settings > Environments so the approval is visible in the Actions run itself, and call the workflow and process “continuous delivery” until that gate is actually removed.

jobs:
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}

Best Practices

  • Set explicit, minimal permissions: at the workflow and job level; never rely on the default token scope for jobs that push images or deploy.
  • Never run a job with deploy secrets in response to a pull_request or pull_request_target event triggered by a fork; restrict deploy jobs to push events on protected branches.
  • Deploy by image digest, not by a mutable tag — a tag can be overwritten after review, a digest cannot.
  • Require the CI status check on the main branch’s protection rule so nothing reaches the delivery or deployment stage without passing tests.
  • Use protected environment: entries with required reviewers to make delivery gates visible and auditable, instead of approvals that happen outside GitHub.
  • Before removing a manual approval gate to move from continuous delivery to continuous deployment, make sure automated health checks and a rollback step already exist and have been tested.
  • Name workflows, jobs, and environments to match what they actually do, so an on-call engineer reading the Actions tab during an incident understands the real release process at a glance.

Practice Exercises

  1. Take the CI workflow from Example 1 and add a step that fails the build if test coverage drops below a threshold, without adding any new permissions.
  2. Starting from the continuous delivery workflow in Example 2, configure a production environment with a required reviewer in your repository settings, and trigger a run to see the job pause and wait for approval.
  3. Modify the continuous deployment workflow in Example 3 so the health check step also posts a failure notification (using a placeholder like ${{ secrets.ALERT_WEBHOOK }}) before the rollback step runs, so the team is notified the same moment production reverts.

Summary

Continuous integration automatically builds and tests every change so integration bugs surface early. Continuous delivery adds the guarantee that every change on main becomes a release-ready, immutable artifact, while still leaving the decision of when to release to a person via a protected environment. Continuous deployment removes that person entirely, shipping every passing change straight to production and relying on automated health checks and rollback to catch what a human reviewer would have caught. The three stages share the same GitHub Actions building blocks — triggers, job dependencies, environments, and scoped permissions — and the difference between them is a small, deliberate configuration choice, not a difference in tooling.