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

- Pull request
- Main branch
- Production approval
- Flag state at T
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.
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.
- Test every pull request. Add the
testjob from the full workflow below, with thepull_requesttrigger and thepushtrigger formain. 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 thetestcheck. 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. - Build once and deploy to staging. Add the
buildjob, which uploadsdist/as an artifact, and thedeploy-stagingjob, which downloads that artifact and deploys it. Every merge tomainnow reaches staging without a manual step. - 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 addmain, so a workflow run from another branch cannot deploy to production. Then add thedeploy-productionjob.
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
testjob runs on every pull request and again on every push tomain, so a change is tested before the merge and again on the main branch after it lands. - The
buildjob runs only on pushes tomainand uploadsdist/once. Both deploy jobs download that same artifact, so staging and production receive identical files. - The workflow-level
concurrencygroup 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_refis empty and the group falls back to the run ID, which is unique, so pushes tomainnever cancel each other at the workflow level. - Each deploy job has its own job-level
concurrencygroup withcancel-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: readlimits 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
testjob 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
mainat 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 brokenmainstarts from a failing state, and the old failure masks any new one. - Long-lived branches: merge to
mainat least once a day. The workflow tests every pull request, but each run tests the branch merged withmainas it was at that run. On a branch that lives for weeks,mainkeeps 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.