Skip to content
LogluneStart free

CI/CD Pipeline: How to Build One in GitHub Actions

By Loglune

A pull request is tested, one build moves from main to staging, and that same artifact is approved for production.
Where a change stops on its way through a GitHub Actions CI/CD pipeline
Pull request
Tests run before the merge
Main branch
One build, deployed to staging
Production approval
A reviewer approves the same build
Flag state at T
Which value one customer received

A CI/CD pipeline is the automated workflow that takes a code change from a commit to running software by building it, testing it, and deploying it. A pipeline you can trust follows three rules: it tests every pull request before the merge, builds the main branch once, and ships that one artifact to staging and then to production.

The rules close a specific gap. A team that runs tests in CI but builds again on a deploy machine ships files that the tests never ran against, and a green check then says little about what is running in production.

Reproduce a customer’s flag state

Start free

CI vs. continuous delivery vs. continuous deployment

The three practices differ in where automation stops. Continuous integration means every developer merges into one shared main branch at least once a day, and an automated build with tests checks each merge. Continuous delivery keeps every passing build ready to release and deploys it automatically as far as staging, while a person decides when it goes to production. Continuous deployment removes that decision, so every change that passes the tests goes to production.

Practice What runs automatically What moves a change into production
Continuous integration Build and tests on every merge to the main branch Not part of the practice
Continuous delivery Build, tests, and deployment to staging A person approves the release
Continuous deployment Every step, including the production deploy Passing tests; no person approves

Continuous delivery fits a product where a release needs a human decision, such as a launch timed with an announcement or a change that compliance staff sign off. Continuous deployment fits a team that already relies on its tests and feature flags for that decision, because a bad change then reaches users within minutes of the merge.

The “CD” in CI/CD is ambiguous. Some teams mean continuous delivery and others mean continuous deployment, and the two differ by one step: the human approval before production. When two teams compare their CI/CD pipelines, the first question is which CD each team runs. A pipeline tool on its own does not make a team practice continuous integration: the daily merge to one main branch is the practice, and the pipeline is the machinery that checks each merge.

The stages of a CI/CD pipeline

A change passes four stages, and a failure at any stage stops the change at that stage and marks the commit or pull request as failed.

  • Trigger: a pull request or a push to the main branch starts a pipeline run.
  • Build: the run installs dependencies and produces one artifact, such as a container image or a dist/ folder.
  • Test: automated tests run against the change. Fast unit tests run first so the result arrives in minutes, and slower end-to-end tests run in a later job.
  • Deploy: the tested artifact goes to staging, then to production after an approval (continuous delivery) or without one (continuous deployment).

In GitHub Actions, the pipeline is a YAML file in .github/workflows/, and each job in that file covers one or more of these stages.

How to set up your first CI/CD pipeline

Add the pipeline in three increments, and let each increment run on real pull requests before adding the next one, so the team trusts each stage before the next stage depends on it.

  1. Test every pull request. Add the test job from the full workflow below, with the pull_request trigger and the push trigger for main. Then make the check required: open the repository’s Settings, open Rulesets in the left sidebar, choose New ruleset and then New branch ruleset, set the enforcement status to Active, add the default branch as a target, and turn on Require status checks to pass before merging with the test check. From then on, a pull request with failing tests cannot merge. Rulesets work in public repositories on GitHub Free and in public and private repositories on GitHub Pro, GitHub Team, and GitHub Enterprise Cloud.
  2. Build once and deploy to staging. Add the build job, which uploads dist/ as an artifact, and the deploy-staging job, which downloads that artifact and deploys it. Every merge to main now reaches staging without a manual step.
  3. Gate production. In Settings, open Environments, choose New environment, and name it production. Select Required reviewers and add up to six people or teams; one approval lets the job run. Select Prevent self-review so the person who started the run cannot approve their own deploy. In the Deployment branches dropdown, choose Selected branches and tags and add main, so a workflow run from another branch cannot deploy to production. Then add the deploy-production job.

To run continuous deployment instead, skip the required reviewers in step 3, and every green push to main reaches production.

These environment settings depend on the GitHub plan. On GitHub Free, GitHub Pro, and GitHub Team, required reviewers work in public repositories only. On GitHub Free, environment secrets also work in public repositories only. In a private repository on GitHub Pro or GitHub Team, deployment branches are available but required reviewers are not, so the production environment deploys without an approval unless the team adds its own approval step.

The complete GitHub Actions workflow

Save the file as .github/workflows/ci-cd.yml. The workflow assumes a Node.js app where npm test runs the tests, npm run build writes the app to dist/, and scripts/deploy.sh is your own script that copies a folder to the named environment.

name: ci-cd

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-cd-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: '24.x'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  build:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: '24.x'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: dist
          path: dist/

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: dist/
      - run: ./scripts/deploy.sh staging dist/

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: dist/
      - run: ./scripts/deploy.sh production dist/

Why each block is in the workflow:

  • The test job runs on every pull request and again on every push to main, so a change is tested before the merge and again on the main branch after it lands.
  • The build job runs only on pushes to main and uploads dist/ once. Both deploy jobs download that same artifact, so staging and production receive identical files.
  • The workflow-level concurrency group uses the branch name on pull requests, so a new commit to a pull request cancels the older run of that pull request. On pushes, github.head_ref is empty and the group falls back to the run ID, which is unique, so pushes to main never cancel each other at the workflow level.
  • Each deploy job has its own job-level concurrency group with cancel-in-progress: false. GitHub runs one job at a time per concurrency group: a later deploy job waits as pending while a deploy runs, and a newer pending job replaces an older pending one. A deploy in progress always finishes, and after several quick merges the newest build deploys next.
  • permissions: contents: read limits the workflow’s token to reading the repository.

The action versions are the latest major releases as of October 6, 2026: actions/checkout v7, actions/setup-node v7, actions/upload-artifact v7, and actions/download-artifact v8. Node.js 24 is the runtime because Node.js 20 reached end of life on April 30, 2026.

On October 6, 2026, we checked this YAML in house with the static checker actionlint 1.7.12, and it reported no errors. We also validated the file against the GitHub workflow JSON schema from SchemaStore, with zero errors, and confirmed that every job named in needs exists. We did not run the workflow end to end, because a run needs your own scripts/deploy.sh and target environments.

Common CI/CD pipeline problems

Most CI/CD pipeline problems come from team habits around the pipeline, not from the YAML.

  • A slow pull request check: keep the test job under ten minutes, the Extreme Programming guideline that Martin Fowler cites in Continuous Integration. Developers switch to other work while a check runs, and a slow check turns one review into several context switches. Move slow end-to-end tests into a job that runs after the merge or against staging.
  • A red main branch that stays red: fix a failed run on main at once, or revert the change with git revert. DORA’s capability page on trunk-based development asks developers to revert when a fix takes more than a few minutes. Every merge on top of a broken main starts from a failing state, and the old failure masks any new one.
  • Long-lived branches: merge to main at least once a day. The workflow tests every pull request, but each run tests the branch merged with main as it was at that run. On a branch that lives for weeks, main keeps moving after the last run, so the green check describes a merge that no longer exists, and conflicts appear all at once at the end.
  • A separate build for each environment: build once and promote the artifact. A second build can differ from the first through a different base image, a build-time variable, or a dependency fetched at a different moment, and then staging tests a file that production never runs.

Separate deploy from release with feature flags

A deploy puts new code on production servers, and a release turns the new behavior on for users. A feature flag keeps the new code path off after the deploy, so the team merges and deploys daily while the release waits. Each flag adds a code branch that tests must cover, and Pete Hodgson’s article Feature Toggles advises that release toggles generally stay no longer than a week or two.

Flags leave a gap in the deploy log. The deploy log shows which build ran at 2:05 p.m., but not whether one customer had the new code path on, because that depends on the flag rules and rollout percentage at that moment.

Loglune rebuilds the flag rules and allocation in effect at a time T as version N. A CI test then sends the customer’s key and attributes with X-Loglune-Version: N, plus evaluatedAt set to T when a rule depends on time.

FAQ

Is GitHub a CI/CD pipeline?

No. GitHub hosts the repository, and GitHub Actions is the GitHub feature that runs CI/CD pipelines from workflow files; GitLab CI/CD, Jenkins, and CircleCI run the same kind of pipeline outside GitHub Actions.

How do you stop third-party actions and pull requests from compromising a GitHub Actions pipeline?

Pin each third-party action to a full-length commit SHA instead of a tag, because a tag can be moved to different code and a commit SHA cannot; GitHub’s secure use reference describes SHA pinning as the way to use an action as an immutable release. The example above uses major-version tags for readability, so replace each tag with the commit SHA of that release before you rely on the workflow. Run tests from pull requests with the pull_request trigger, as the example does, and avoid pull_request_target for code from forks: a pull_request_target run is privileged, can have write access and secrets, and lets code from an untrusted pull request take over the repository if the workflow checks that code out.

Check which flag value a customer received after a CI/CD pipeline release

Pick a customer and a time T. Loglune rebuilds the flag state at T from the change history and delivers it in reproduction mode to your development, CI, or staging environment. The Free plan includes 5 reproductions and stops at the cap.