GitHub Actions Tutorial: Your First Workflow That Runs Tests
Write your first GitHub Actions workflow file, understand triggers, jobs and steps, add secrets safely, and run tests on every push with copy-paste YAML.

A GitHub Actions workflow is a YAML file in your repository's .github/workflows directory. It names the events that start it, such as on: [push], and one or more jobs that run on a GitHub-hosted machine, each as an ordered list of steps. Commit the file and GitHub runs it the next time the event fires. Results show up under the Actions tab of the repo.
That's the whole mental model. The rest of this tutorial builds the file up from the smallest version that works: first a one-step hello workflow, then the official demo with its context variables, then a checkout plus test command that runs on every push, then a secret passed into a step. Each snippet is copy-paste ready.
What GitHub Actions is, in plain terms#
GitHub Actions is GitHub's continuous integration and continuous delivery (CI/CD) platform. It automates your build, test and deployment pipeline in response to things that happen in the repository. The official quickstart gives the two classic uses: run tests whenever you push a change, or deploy merged pull requests to production.
Everything is configured by files in the repo, so the pipeline is versioned alongside the code it tests. Four terms carry the whole system:
- Workflow. One YAML file under
.github/workflows. A repo can hold many of them. - Event. What triggers a run. A push is the one you'll use first.
- Job. A unit of work that runs on a fresh runner machine. The examples here use
runs-on: ubuntu-latest, a Linux server hosted by GitHub. - Step. A single shell command (
run) or a reusable action (uses) inside a job. Steps execute in order on the same machine, so later steps see files created by earlier ones.
If the Actions tab isn't visible under your repository name, Actions is probably disabled in the repository settings. Turn it on before continuing.
Your first workflow file#
Create a file at .github/workflows/github-actions-demo.yml. Two rules decide whether GitHub notices it: the file has to live in a directory called exactly .github/workflows, and it has to end in .yml or .yaml. The filename itself is up to you.
Here's the smallest workflow that does something visible:
name: Hello
on: [push]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo "Hello from GitHub Actions"
Line by line: name is the label you'll see in the Actions sidebar. on: [push] means every push to any branch starts a run. jobs holds a map of job IDs, here just hello. Each job declares where it runs (runs-on) and what it does (steps). A step with run executes a shell command on the runner.
Once that's clear, the official demo workflow is the next step. It has the same shape and adds run-name, a checkout action, and several expressions in ${{ }} that read values from the run:
name: GitHub Actions Demo
run-name: ${{ github.actor }} is testing out GitHub Actions 🚀
on: [push]
jobs:
Explore-GitHub-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!"
- run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}."
- name: Check out repository code
uses: actions/checkout@v6
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ github.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
To add it through the GitHub web UI, open the repository, click Add file, then Create new file, and type the full path .github/workflows/github-actions-demo.yml as the filename. GitHub creates the directories for you. Paste the YAML and click Commit changes. You can commit straight to the default branch or to a new branch with a pull request. Either way, the commit is a push, and the push triggers the workflow immediately.
To see it run: click Actions under the repo name, pick the workflow in the left sidebar, click the run (its title comes from run-name, so it reads "USERNAME is testing out GitHub Actions"), then click the job under Jobs. Each step expands to show its log. The "List files in the repository" step prints your repo's files, which proves the checkout worked.
Triggers, jobs and steps#
Triggers. The on key takes a list of events. on: [push] is the simplest. If you want the same workflow to run for pull requests too, use on: [push, pull_request]. Inside the run, ${{ github.event_name }} tells you which event actually fired, which is handy when one workflow serves several triggers.
Jobs. Each key under jobs is a job ID, and each job gets its own fresh runner. Nothing on disk carries over from one job to another unless you explicitly pass it along. Pick job IDs that read well in the sidebar; test, build and deploy are typical.
Steps. A step is either run (a shell command) or uses (a published action, referenced as owner/repo@version). name is optional on both, but a named step is far easier to find in the log. For multi-line commands, use run: | and indent the lines beneath it, as the demo does with ls.
The checkout step deserves a note. A runner starts empty. It does not have your repository's files until actions/checkout@v6 clones them into ${{ github.workspace }}. Any step that reads your code, so any test or build, must come after checkout.
Here are the keys and context values from the quickstart, in one place:
| Key or expression | What it is |
|---|---|
name |
Workflow label shown in the Actions sidebar |
run-name |
Title for each individual run; can use expressions |
on |
List of events that trigger the workflow |
jobs |
Map of job IDs to job definitions |
runs-on |
Runner type for a job, e.g. ubuntu-latest |
steps |
Ordered list of steps in a job |
run |
Shell command to execute |
uses |
Action to run, e.g. actions/checkout@v6 |
${{ github.actor }} |
User who triggered the run |
${{ github.event_name }} |
Event that fired, e.g. push |
${{ github.ref }} |
Branch or tag reference |
${{ github.repository }} |
owner/name of the repo |
${{ github.workspace }} |
Directory where checkout puts your code |
${{ runner.os }} |
Operating system of the runner |
${{ job.status }} |
Current status of the job |
Expressions are substituted before the shell sees the command. The short script below mimics that substitution so you can see what the demo's echo lines will produce before you push anything:
// Mimic how ${{ ... }} expressions get filled in from the run's context.
const context = {
github: {
actor: 'indra',
event_name: 'push',
ref: 'refs/heads/main',
repository: 'coremesh/demo',
},
runner: { os: 'Linux' },
};
const lines = [
'The job was automatically triggered by a ${{ github.event_name }} event.',
'This job is now running on a ${{ runner.os }} server hosted by GitHub!',
'The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}.',
'Run title: ${{ github.actor }} is testing out GitHub Actions',
];
const lookup = (path) =>
path.split('.').reduce((obj, key) => (obj ? obj[key] : undefined), context);
for (const line of lines) {
const rendered = line.replace(/\$\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => lookup(path) ?? '');
console.log(rendered);
}
A test-on-push workflow you can copy#
This is the workflow most people actually want from a GitHub Actions tutorial: check out the code, install dependencies, run the test suite, and fail loudly if anything breaks.
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Save it as .github/workflows/tests.yml. On every push and every pull request, GitHub spins up an Ubuntu runner, clones the repo, and runs those two commands. If any run step exits non-zero, the step fails, the job fails, and the commit gets a red X instead of a green check.
Swap the two commands for whatever your project uses. A Python project might run pip install -r requirements.txt and then python -m pytest, a Go project go test ./..., and so on. The shape of the workflow doesn't change.
If your project needs a specific language or toolchain version, I'd start from one of GitHub's workflow templates rather than writing the setup by hand. When you open the Actions tab, GitHub analyzes the code and suggests templates for it; a Node.js repo gets Node.js suggestions, for example. The full set lives in the actions/starter-workflows repository, grouped into CI, deployments, automation, code scanning and Pages. Pick one, trim what you don't need, and keep the toolchain setup step it ships with.
Using secrets without leaking them#
Sooner or later a step needs a token: a deploy key, a package registry credential, an API key. Never paste it into the YAML. The workflow file is committed to the repo, visible in history, and readable by anyone who can see the code.
Instead, store the value as an Actions secret in the repository settings and reference it with the secrets context. GitHub injects it at run time and masks it in logs. The usual pattern is to pass it to a step as an environment variable:
- name: Deploy
run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
Your script then reads API_TOKEN from its environment like any other variable. Don't echo the secret to check it's there; even with masking, that's a habit that eventually bites. The official page on using secrets in GitHub Actions covers where to create them and how organization and environment scopes work.
Common mistakes in a first workflow#
- The file is in the wrong place.
.github/workflow/ci.yml(singular) orworkflows/ci.ymlat the repo root are silently ignored. It must be.github/workflows/with a.ymlor.yamlextension. The script below checks a path against those two rules. - Broken YAML indentation. YAML is whitespace-sensitive. A
steps:list indented one space off fails before any step runs. Use two spaces consistently, and check that every- run:or- name:is aligned with its siblings. - Running tests before checkout. The runner has no copy of your code until
actions/checkout@v6runs. If your test step reports missing files or an empty directory, look at the step order. - Expecting the workflow to run on older commits. A workflow starts running from the push that adds it. Nothing happens to history before that commit.
- No Actions tab. If you can't find it, Actions is disabled for the repository. Check the repository's Actions settings.
- Treating jobs like steps. Steps in one job share a machine; separate jobs don't. If step two can't find what step one produced, make sure they're in the same job.
- Typos in
uses. The action reference isowner/repo@version, soactions/checkout@v6. A misspelled owner or a missing version fails at the start of the step. - Secrets in plain text. Covered above, but it's the mistake with the longest tail. Rotate anything you've ever committed.
# Will GitHub discover this workflow file? Checks the two rules from the quickstart.
from pathlib import PurePosixPath
WORKFLOW_DIR = PurePosixPath('.github/workflows')
def is_workflow_path(path):
p = PurePosixPath(path)
in_dir = p.parent == WORKFLOW_DIR
good_ext = p.suffix in ('.yml', '.yaml')
return in_dir and good_ext
candidates = [
'.github/workflows/ci.yml',
'.github/workflows/deploy.yaml',
'.github/workflow/ci.yml',
'workflows/ci.yml',
'.github/workflows/ci.txt',
'.github/workflows/nested/ci.yml',
]
for candidate in candidates:
verdict = 'discovered' if is_workflow_path(candidate) else 'ignored'
print(f'{candidate:36} {verdict}')
Quick reference#
Keep this near you while you write the first few workflows.
- File location:
.github/workflows/<anything>.ymlor.yaml. - Minimum keys:
name,on,jobs, one job withruns-onandsteps. - Trigger on pushes:
on: [push]. Addpull_requestto cover PRs too. - Linux runner:
runs-on: ubuntu-latest. - Get your code onto the runner:
uses: actions/checkout@v6, before any step that reads files. - Shell command:
run: your-command. Multi-line:run: |then indented lines. - Name steps with
name:so logs are readable. - Secrets: create in repository settings, reference with
${{ secrets.NAME }}, pass throughenv. - Results: Actions tab, then workflow, then run, then job, then expand a step.
- Full key reference: workflow syntax for GitHub Actions. Starting point: the GitHub Actions quickstart.
Quick answers
Is GitHub Actions hard for beginners?
No. A working workflow is seven lines of YAML, and GitHub suggests templates for your language when you open the Actions tab. Start with the hello example, then move to the test-on-push one.
What are steps in GitHub Actions?
Steps are the individual units inside a job. Each one is either a run shell command or a uses action such as actions/checkout@v6, and they execute in order on the same runner.
How do I use GitHub Actions for CI/CD?
For CI, trigger on push and pull_request, check out the code, and run your tests. For CD, add a job that deploys using credentials stored as repository secrets. GitHub's workflow templates cover both cases.
What is the simplest GitHub Actions example?
A file in .github/workflows with on: [push], one job on ubuntu-latest, and a single run: echo step. Commit it and it runs on that very push.
Do I need to install anything to use GitHub Actions?
No. Workflows run on GitHub-hosted runners such as ubuntu-latest. You only need a repository with Actions enabled and a YAML file in the right directory.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note