GitHub Actions for CI/CD: A First Workflow That Tests and Deploys
One YAML file that runs your tests on every pull request and deploys when a change is merged to main. The whole workflow, then each part: triggers, jobs, runners, a matrix, caching, secrets, and an environment that waits for a person to approve.
8 min read
GitHub Actions runs workflows, which are YAML files in your repository’s .github/workflows folder, on machines GitHub provides whenever something happens in the repository. A first CI/CD workflow needs two jobs. The test job runs on every pull request to main, across a small matrix of language versions, with dependencies cached so it stays fast. The deploy job runs only when a change lands on main, only after the tests pass, and only once a named person approves it through a protected environment that also holds the deploy secret. The complete file is below, followed by what each part does and the settings that trip people up. Everything here follows GitHub’s documentation as of September 30, 2026.
The pieces, in one minute
- Workflow: one YAML file in
.github/workflows. A repository can have many. - Event: what starts a workflow, such as a pull request being opened or a push to a branch.
- Job: a set of steps that runs on one machine. Jobs in a workflow run in parallel unless one
needsanother. - Step: a shell command (
run) or a packaged action (uses). - Runner: the machine a job runs on.
ubuntu-latestis a GitHub-hosted virtual machine, fresh for each job. - Action: a reusable step someone published, such as
actions/checkout, which fetches your code.
The whole workflow
This example is a Node.js project with npm test, npm run build and a deploy script. Swap those three commands for your own stack; the structure stays the same.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
deploy:
if: github.event_name == 'push'
needs: test
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
concurrency:
group: production
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
- run: npm run deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Triggers: pull requests and merges to main
The on: block lists two events. pull_request with branches: [main] runs the workflow for pull requests that target main. GitHub’s list of events that trigger workflows (opens in a new tab) says that, by default, a pull request run starts when the pull request is opened, synchronized (new commits pushed) or reopened, which is what CI needs.
push with branches: [main] fires when commits land on main, and with branch protection in place that means a merged pull request. The deploy job’s if: github.event_name == 'push' keeps it from running on pull requests at all, so a pull request only ever tests.
Pull requests from forks are treated with suspicion by design. Secrets other than GITHUB_TOKEN are not passed to those runs, and the token is read-only, so a stranger’s pull request can run your tests without reaching your deploy credentials.
Jobs, runners and permissions
Jobs run in parallel by default. needs: test makes the deploy job wait for every matrix run of test to succeed, and skip if any fails. The workflow syntax reference (opens in a new tab) covers each key used here; three are worth setting on day one:
permissions: contents: readat the top. It limits what the automaticGITHUB_TOKENcan do, and once you name any permission, every one you did not name is set to none.timeout-minuteson each job. The default is 360 minutes, so a hung test can burn six hours before GitHub cancels it.concurrencyon the deploy job, so two merges in quick succession deploy one after the other instead of at the same time. Withcancel-in-progress: false, a running deploy is never cut off halfway.
On GitHub-hosted runners, each job gets a new virtual machine. GitHub’s runner reference says the standard runners are free and unlimited on public repositories; private repositories draw on the free minutes included with your account’s plan, then are billed per minute. Check your plan’s allowance under your billing settings before adding large matrices.
Matrix builds
The strategy.matrix block turns one job definition into several. GitHub’s guide to running variations of jobs (opens in a new tab) describes it as a way to “automatically create multiple job runs that are based on the combinations of the variables.” Here, node-version: [22, 24] runs the tests twice, once on each version, as two separate jobs.
- Add a second variable, such as
os: [ubuntu-latest, windows-latest], and you get every combination: four jobs. - By default, one failing combination cancels the others still running. Set
fail-fast: falseif you want to see every result. max-parallelcaps how many combinations run at once, which helps when minutes are tight.
Caching dependencies
cache: npm on actions/setup-node saves the npm download cache between runs, keyed on a hash of package-lock.json, so a run whose lockfile has not changed restores it instead of downloading everything again. It does not cache node_modules itself. The setup actions for Python, Java, Ruby, Go and .NET offer the same built-in caching for their package managers.
GitHub’s dependency caching reference (opens in a new tab) sets the limits: a cache entry not used for 7 days is removed, and a repository gets 10 GB of cache by default before the oldest entries are evicted. It also gives the one security rule to remember: “Don’t store sensitive information in a cache,” because anyone who can open a pull request against your repository can read the caches on the base branch.
Secrets
The deploy step reads secrets.DEPLOY_TOKEN, the credential your hosting provider or server accepts. GitHub’s page on using secrets (opens in a new tab) describes three levels, repository, environment and organization; for a deploy key, use the environment level, covered next. You can add one in the browser or from the GitHub CLI:
# A secret only jobs using the production environment can read gh secret set --env production DEPLOY_TOKEN # List what is set gh secret list --env production
- Pass secrets to steps as
envorwithinputs, as GitHub’s own examples do, rather than pasting the expression into the middle of a shell command. - Secrets are masked in logs. A value you compute at run time is not, unless you mask it with
::add-mask::. - If you deploy to a cloud provider that supports OpenID Connect, GitHub’s docs describe authenticating that way instead, so there is no long-lived key to store at all.
Environments with required reviewers
environment: production on the deploy job is what turns “deploy on merge” into “deploy on merge, once someone says yes.” Create the environment under Settings, then Environments, and add protection rules. GitHub’s reference on deployments and environments (opens in a new tab) explains the ones a small team uses:
- Required reviewers: up to six people or teams, of whom one must approve before the job runs. The run waits until someone does.
- Prevent self-reviews: the person who triggered the deploy cannot approve it, so every production deploy has two people behind it.
- Deployment branches and tags: restrict the environment to
main, so a workflow on another branch cannot deploy even if someone edits the YAML. - Environment secrets: a job cannot read them until the required reviewers approve, which keeps
DEPLOY_TOKENlocked away until then.
Check your plan before relying on this. GitHub’s documentation says that on GitHub Free, GitHub Pro and GitHub Team plans, required reviewers and wait timers are available only for public repositories. Environments, environment secrets and deployment branches work in private repositories on GitHub Pro, GitHub Team and GitHub Enterprise. So a private repository on GitHub Team can still limit deploys to main and hold the secret in the environment, but a required approval on a private repository means GitHub Enterprise.
Make the tests a merge requirement
A test job that fails but does not block merging is advice, not CI. In the repository’s branch protection rules or rulesets for main, require pull requests before merging and require the test checks to pass. Add a required review as well. From then on, the only way onto main is a reviewed pull request with green tests, and the only way to production is the approved deploy job.
Things that trip people up
- Action versions. GitHub’s own examples still show
actions/checkout@v6, while the checkout action’s releases list v7, published in July 2026. Both run; check each action’s releases page, and for third-party actions consider pinning to a full commit SHA, as GitHub’s security guidance suggests. - The Run workflow button. A workflow you want to start by hand needs the
workflow_dispatchtrigger, and GitHub shows the button only once the workflow file is on the default branch. - Pull requests opened by automation. GitHub says pull request runs created by a workflow using
GITHUB_TOKENneed approval from someone with write access before they run. - Secrets in
if:conditions. They cannot be referenced there directly; set them as job-level environment variables and test those instead.
Running an AI coding assistant as a step in this kind of pipeline is its own topic: Claude Code GitHub Actions covers setup and safe uses. When agents open the pull requests themselves, agentic workflows in GitHub covers the review loop.
Tracking the work around the pipeline
A green check says the tests passed; it does not say whether the work the pull request was for is finished. If your work is on a fenbs board, put the task’s ref, such as BUG-042, in the pull request title, and after the deploy set the task’s test status to Tested with a line of test notes on what was checked, then move it to Completed. fenbs has no GitHub integration that does this for you, so a person does it, or an AI assistant connected over MCP with a hand-issued token that has a name, only the scopes it needs and an expiry. Either way, History records who changed what. Where the steps belong in your release routine, SOP examples includes a code release procedure to adapt.
Related
How branches reach main: git branching strategy. What reviewers need in a pull request: pull request template. What to check before a release: regression testing checklist. Developers on fenbs: fenbs for developers.