Skip to main content

GitHub Actions for Beginners: Understanding Triggers, Conditions, Variables and Debugging Your Workflows

Part 2 of 5 in a series that takes you from your first workflow to production-grade automation

Updated 10 min readMarkdown
Part of seriesGitHub Actions - Zero to Production
GitHub Actions for Beginners: Understanding Triggers, Conditions, Variables and Debugging Your Workflows

Is your pipeline running only when it should?

In Part 1 we built a pipeline that lints, tests, and builds on every push. That’s a good start, but a pipeline that runs on everything isn’t really required all the times.

Getting it right means answering four questions about every workflow:

  1. When should it run? That’s what triggers control.
  2. Should this job or step run at all? That’s what conditions decide.
  3. What values do the steps work with, and how do they share them? That’s variables and outputs.
  4. What do I do when its not working? That’s debugging.

This post answers each one with working examples. By the end, you’ll have upgraded the Part 1 pipeline so it runs only when it needs to, and you’ll know how to troubleshoot if something is wrong.

1. Triggers: when does a workflow run?

Everything under the on: key is a trigger. A workflow can listen to one event or several.

The events we will use most:

Event Runs when
push Commits are pushed to a branch or tag
pull_request A PR is opened, updated, or reopened
workflow_dispatch Someone triggers it manually
schedule you save the slot
release A GitHub release is created or published
workflow_call Another workflow calls this one
workflow_run Another workflow finishes

There are many more, including events for issues, comments, and labels, which is what makes Actions useful for repository automation as well as CI.

Filtering by branch, tag, and path:

Without filters, push runs for every branch. Usually not necessary in most of the cases:

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

Understanding the above yaml snippet: The workflow triggers on commit and PRs targeting main branch only, any updates to those specific files and folders mentioned under inbuilt keyword paths-ignore will not trigger the workflow run.

A few rules to know:

  • You can’t use paths and paths-ignore for the same event. Pick one or use ! with paths. The same goes for branches, tagsand branches-ignore, tags-ignore.
  • Patterns use globs. * matches within one folder level, and ** matches across folders.
  • Quote patterns that start with *, or YAML will try to read them as something else.
  • Filters are case-sensitive on Linux runners.

To trigger on release tags like v1.4.2:

on:
  push:
    tags:
      - 'v*.*.*'

Pull request activity types:

By default, pull_request runs when a PR is opened, synchronized (new commits pushed), or reopened. You can add more types when you need them:

on:
  pull_request:
    types: [opened, synchronize, reopened]

Running a workflow manually:

workflow_dispatch adds a "Run workflow" button to the Actions tab and lets you ask for inputs:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Where should this run?
        type: choice
        options: [staging, production]
        default: staging
      verbose:
        description: Print extra logs
        type: boolean
        default: false

One catch: the button only shows up once the workflow file exists on your default branch.

Running on a schedule:

on:
  schedule:
    - cron: '0 6 * * 1'   # every Monday at 06:00 UTC

The five fields are minute, hour, day of month, month, and day of week.

2. Conditions: should this job or step run?

Triggers decide whether the workflow starts. The if key then decides whether an individual job or step runs. In the below case, the first if says execute the job only when the main branch is committed and second if says execute if it’s a Linux runner.

jobs:
  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - name: Only run on Linux
        if: runner.os == 'Linux'
        run: echo "Running on Linux"

Job execution is skipped as it didn’t meet the defined conditions

Job execution is successful as the required conditions are met

Status functions:

By default every step has an invisible if: success(), meaning "run only if everything before passed." You can change that:

Function True when
success() All previous steps passed (the default)
failure() Any previous step failed
always() Always, even if the run was cancelled
cancelled() The run was cancelled

Here's a pattern you'll use constantly, running cleanup or a report even when the tests fail:

steps:
  - name: Run tests
    run: npm test

  - name: Runs only if tests pass
    run: echo "All tests passed"

  - name: Runs only if something failed
    if: failure()
    run: echo "Something failed, check the logs above"

  - name: Runs no matter what
    if: always()
    run: echo "Finished, whatever the result"

3. Variables and data: env, vars, secrets, and outputs

There are three ways to give a workflow configuration, and they're easy to mix up.

Mechanism Defined Read with Use for
env In the YAML itself ${{ env.NAME }} Values that belong in the workflow file
vars In repository, environment, or organization settings ${{ vars.NAME }} Non-sensitive information
secrets In repository, environment, or organization settings, stored encrypted ${{ secrets.NAME }} Sensitive information

env can be set at three levels, and the closest one wins:

env:
  APP_ENV: ci                            # at workflow level

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      LOG_LEVEL: info                    # at job level
    steps:
      - name: Run tests
        env:
          API_URL: ${{ vars.API_URL }}   # at step level
        run: npm test

GitHub supports both default variables as mentioned below and custom variables (using env and var):

- name: Show run info
  run: |
    echo "Commit:  $GITHUB_SHA"
    echo "Branch:  $GITHUB_REF_NAME"
    echo "Actor:   $GITHUB_ACTOR"
    echo "Run #:   $GITHUB_RUN_NUMBER"
    echo "Runner:  $RUNNER_OS"
    echo "CI flag: $CI"

Passing data between steps and jobs:

In certain scenarios you may need the output from one step to be passed onto another in the same job or across different jobs, let’s see how we can achieve it.

Across steps in the same job: For every run, a step starts in a fresh shell. A variable you set in one step is gone by the next:

steps:
  - name: Step one
    run: name=Adithya Meda

  - name: Step two
    run: echo "Hello $name"    # prints "Hello " because the variable is empty

To pass a value to a later step, write it to a default key $GITHUB_OUTPUT, then read it back by the step’s id:

steps:
  - name: Step one
    id: one
    run: echo "name=Adithya Meda" >> "$GITHUB_OUTPUT"

  - name: Step two
    run: echo "Hello ${{ steps.one.outputs.name }}"   # prints "Hello AdithyaMeda"

How to define:

  1. Give the first step an id.
  2. Write <output name>=<value> to $GITHUB_OUTPUT.
  3. Read it later with steps.<id>.outputs.<output name>.

Across different jobs:

jobs:
  job-one:
    runs-on: ubuntu-latest
    outputs:
      name: ${{ steps.one.outputs.name }}
    steps:
      - id: one
        run: echo "name=AdithyaMeda" >> "$GITHUB_OUTPUT"

  job-two:
    needs: job-one
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello ${{ needs.job-one.outputs.name }}"

The key difference to notice is that when passing output within the same job we use steps. and across the jobs, we use needs.

Defaults, timeouts, and failure handling:

A few settings that make workflows more predictable:

defaults:
  run:
    shell: bash
    working-directory: ./app

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Experimental check
        continue-on-error: true
        run: npm run experimental-check
  • timeout-minutes matters more than it looks. The default job timeout is 360 minutes, so a hung process can quietly burn six hours of runner time. Set something realistic.
  • continue-on-error: true lets a step or job fail without failing the run. Good for experimental checks, but not for production systems.
  • defaults keyword defines fallback execution settings (like shell or working-directory) at the workflow or job level.

4. Debugging: when something goes wrong

Why isn’t my workflow running?

  • Wrong location or extension. It must live in .github/workflows/ and end in .yml or .yaml.
  • Invalid YAML. Syntax errors in the workflow files. One bad indent is enough.
  • Filters excluded it. Check branches, tags, and paths against what you actually pushed.
  • Default branch rule. schedule and the manual "Run workflow" button need the file on the default branch.
  • A commit message skipped it. [skip ci] or [ci skip] in a commit message skips push and pull_request workflows for that commit.
  • Actions is disabled for the repository until the owner enables it.

Some debugging actions to consider:

  1. Start with the logs: If everything is fine and your workflow runs and fails due to other reasons, open the failed run, click the failed job, and look for the red step. Each step is collapsible, and you can search within the log using the search box at the top.
  2. Turn on debug logging: When the normal log isn’t enough, do this: On a failed run, click Re-run failed jobs and tick Enable debug logging. This is the easiest route or set ACTIONS_STEP_DEBUG to true as a repository secret or variable to get verbose step output on every run. ACTIONS_RUNNER_DEBUG does the same for runner diagnostics.
  3. Make your own logs readable: Workflow commands let you format output and raise annotations that show up directly on the run and the pull request:
- name: Check things
  run: |
    echo "::group::Environment details"
    node --version
    npm --version
    echo "::endgroup::"
    echo "::notice::Using Node $(node --version)"
    echo "::warning file=src/app.js,line=12::Consider validating this input"
    echo "::error file=src/app.js,line=20::This will break on Windows"

::group:: collapses noisy output, and ::error, ::warning, and ::notice appear as annotations. Adding file and line pins them to a specific line in the diff.

Common symptoms and likely causes:

Symptom Likely cause
Workflow never appears Wrong folder, invalid YAML, or not on the default branch
A job is "skipped" and a dependent job too A needs job was skipped or failed, or the if was false
Step passes locally, fails in CI Different Node or OS version, missing env var, or a missing/wrong commands
Resource not accessible by integration The GITHUB_TOKEN lacks a permission (Part 3)
A paths filter blocks a required check The workflow was skipped, so the check never reported

Most broken pipelines come from small mistakes, like a filter that’s not properly applied or a value that never reached the next step.

What's next in the series:

Part 1: GitHub Actions fundamentals and your first pipeline.

Part 2 (this post): Workflows in depth: triggers, conditions, environment variables, and debugging.

Part 3: Secrets, permissions, and securing your pipelines.

Part 4: Deployments: environments, approvals, and shipping to the cloud.

Part 5: Advanced patterns: reusable workflows, composite actions, and self-hosted runners.

Find all workflows used in this series on my GitHub Actions: Zero to Production repository.

About the author

  • Senior DevOps Engineer @Canarys Automations | GitHub, AWS, Docker, K8s, Terraform, CI/CD | Accelerating infrastructure & automation using AI tools (Copilot, Claude).

No comments yet. Be the first to comment.

GitHub Actions - Zero to Production

Part 1 of 2

In this five-part series of posts, I would be sharing hands-on guide to GitHub Actions, from CI/CD fundamentals to production-ready pipelines. Each post pairs clear explanations with examples, so you can build, test, secure, and deploy your projects with confidence.

Up nextGitHub Actions for Beginners: Understanding CI/CD and Building Your First PipelinePart 1 of 5 in a series that takes you from your first workflow to production-grade automation