CoreMesh

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.

GitHub Actions Tutorial: Your First Workflow That Runs Tests

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:

yaml
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:

yaml
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:

javascript
// 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.

yaml
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:

yaml
      - 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) or workflows/ci.yml at the repo root are silently ignored. It must be .github/workflows/ with a .yml or .yaml extension. 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@v6 runs. 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 is owner/repo@version, so actions/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.
python
# 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>.yml or .yaml.
  • Minimum keys: name, on, jobs, one job with runs-on and steps.
  • Trigger on pushes: on: [push]. Add pull_request to 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 through env.
  • 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.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

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

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS