Basics Lesson 2 of 14

GitHub Actions Fundamentals

Events, workflows, jobs, steps, actions — understand the core building blocks.

🧒 Simple Explanation (ELI5)

Imagine a giant domino chain. Someone pushes the first domino — that's an event (like pushing code to GitHub). The entire domino path laid out on the table is the workflow. Along the path, dominoes are grouped into sections separated by little bridges — those sections are jobs. Within each section, every single domino that falls is a step — they fall one after another in order.

Now here's the cool part: some of those dominoes aren't just plain tiles. They're fancy pre-built domino gadgets — little machines that spin, launch a ball, or ring a bell before tipping the next domino. Those gadgets are actions. Someone else built them, and you just plug them into your chain. You didn't have to engineer the spinning gadget yourself — you found it in the marketplace (a store full of pre-built gadgets) and dropped it into your path.

The full picture: You push the first domino (event). The chain (workflow) starts falling. Section 1 (Job 1) runs its dominoes one by one (steps). Some are plain tiles (shell commands), some are fancy gadgets (actions). Meanwhile, Section 2 (Job 2) on a separate table starts falling at the same time — jobs run in parallel by default. When all sections finish, the chain is complete and you get your result.

If any domino in a section fails to fall (a step errors out), the rest of that section stops. But the other sections on their separate tables keep going — unless you told them to wait.

🔧 Technical Explanation

Core Concepts Overview

GitHub Actions has six fundamental building blocks. Every workflow you write is composed of these elements:

ConceptWhat It IsAnalogy
EventA trigger that starts a workflow — a push, PR, schedule, manual button click, or external webhookPushing the first domino
WorkflowA YAML file in .github/workflows/ that defines the entire automation pipelineThe complete domino path blueprint
JobA unit of work that runs on a single runner (virtual machine). Jobs run in parallel by default.A section of dominoes on its own table
StepA single task within a job — either a shell command (run:) or an action (uses:). Steps run sequentially.An individual domino falling
ActionA reusable, pre-built unit of code that performs a specific task. Published on the GitHub Marketplace.A fancy pre-built domino gadget
RunnerThe server (virtual machine) that executes a job. GitHub-hosted (ubuntu, windows, macos) or self-hosted.The table the dominoes sit on

Events Deep Dive

Events are what trigger your workflow to start. GitHub supports dozens of event types. Here are the most important ones:

push — Fires when commits are pushed to a branch. The most common trigger for CI pipelines.

yaml
on:
  push:
    branches: [main, develop]       # Only trigger on these branches
    paths: ['src/**', 'tests/**']   # Only trigger when these files change

pull_request — Fires when a PR is opened, updated (pushed to), or synchronized. Essential for running checks before merge.

yaml
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

schedule — Cron-based trigger for running workflows on a schedule. Perfect for nightly builds, weekly reports, or periodic cleanup.

yaml
on:
  schedule:
    - cron: '0 2 * * *'   # Every day at 2:00 AM UTC

workflow_dispatch — Adds a "Run workflow" button in the GitHub Actions UI. Allows manual triggering with optional custom inputs.

yaml
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

repository_dispatch — Triggered by an external system via the GitHub API. Used for integrating third-party tools or cross-repo orchestration.

yaml
on:
  repository_dispatch:
    types: [deploy-request]   # Custom event type you define

release — Fires when a release is published, created, or edited. Common trigger for publishing packages or deploying production.

yaml
on:
  release:
    types: [published]

Workflows

A workflow is a YAML file stored in the .github/workflows/ directory of your repository. Each file defines one complete automation pipeline. Key rules:

Jobs — Parallel by Default

Jobs define what to run and where to run it. Each job gets its own fresh runner (virtual machine). Critical behavior:

yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

  test:
    runs-on: ubuntu-latest
    needs: build               # Waits for build to finish first
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    needs: [build, test]       # Waits for BOTH build and test
    if: github.ref == 'refs/heads/main'
    steps:
      - run: echo "Deploying to production..."

Steps — Sequential Within a Job

Steps are the individual tasks within a job. They execute sequentially — step 2 waits for step 1 to finish. Each step is either:

Steps share the same runner filesystem, so files created in step 1 are available in step 2. They also share environment variables set with $GITHUB_ENV.

Actions — Reusable Building Blocks

Actions are pre-built, reusable units that perform specific tasks. Instead of writing 50 lines of shell commands, you reference an action with one line. The most commonly used actions:

ActionWhat It DoesUsage
actions/checkout@v4Clones your repository into the runnerRequired in almost every job
actions/setup-node@v4Installs a specific Node.js versionNode.js projects
actions/setup-python@v5Installs a specific Python versionPython projects
actions/cache@v4Caches dependencies between runsSpeed up builds
actions/upload-artifact@v4Saves files from a job for later useShare build output between jobs
actions/download-artifact@v4Downloads artifacts from previous jobsRetrieve build output

Actions are version-pinned using tags (e.g., @v4) or commit SHAs for maximum security (e.g., @a5ac7e51b41094c92402da3b24376905380afc29).

GitHub Context Objects

GitHub provides context objects accessible in workflow expressions using the ${{ }} syntax. These give you metadata about the event, repository, and runner:

ContextValueExample Use
github.shaThe commit SHA that triggered the workflowTagging Docker images: myapp:${{ github.sha }}
github.refThe branch or tag ref (e.g., refs/heads/main)Conditional deploys: if: github.ref == 'refs/heads/main'
github.actorThe username who triggered the workflowNotifications: "Deployed by ${{ github.actor }}"
github.event_nameThe event type (push, pull_request, schedule, etc.)Conditional logic based on trigger type
github.repositoryOwner and repo name (e.g., octocat/my-app)Building registry paths
github.run_idUnique numeric ID for the current workflow runLinking to logs
github.workspaceThe working directory on the runnerFile path references

📊 GitHub Actions Architecture

Event → Workflow → Jobs → Steps → Output
Event (push)
Workflow (.yml)
Job 1: build
Step A: checkout
Step B: npm build
|
Job 2: test
Step C: checkout
Step D: npm test
Output (Pass / Fail)
GitHub Marketplace Ecosystem
Your Workflow
uses:
GitHub Marketplace (20,000+ Actions)
actions/checkout
actions/setup-node
docker/build-push-action
azure/login
Community Actions
Reusable, Pre-Built Logic

💻 Code Example — Complete Workflow Explained

Here's a complete CI workflow with every single line annotated:

yaml
# name: Human-readable name for this workflow.
#   Appears in the Actions tab of your repository.
name: CI Pipeline

# on: Defines which events trigger this workflow.
#   This workflow runs on two events: push and pull_request.
on:
  # push: Triggered when commits are pushed to the repository.
  push:
    # branches: Only trigger on pushes to the 'main' branch.
    #   Pushes to feature branches are ignored.
    branches: [main]

  # pull_request: Triggered when a PR is opened or updated.
  pull_request:
    # branches: Only trigger for PRs targeting the 'main' branch.
    #   PRs targeting other branches are ignored.
    branches: [main]

# jobs: Defines all the jobs in this workflow.
#   Each job runs on its own virtual machine (runner).
jobs:

  # 'build' is the job ID — used by other jobs to reference this one.
  #   You can name it anything: build, ci, lint, etc.
  build:

    # runs-on: Specifies the runner (virtual machine) for this job.
    #   'ubuntu-latest' gives you a fresh Ubuntu VM with common tools
    #   pre-installed (git, node, python, docker, etc.).
    runs-on: ubuntu-latest

    # steps: The sequential list of tasks this job performs.
    #   Each step runs in order. If one fails, the rest are skipped.
    steps:

      # uses: Run a pre-built action from the GitHub Marketplace.
      #   actions/checkout@v4 clones your repository code onto the runner.
      #   Without this, the runner has no access to your code.
      - uses: actions/checkout@v4

      # name: Human-readable label for this step (appears in logs).
      # uses: actions/setup-node@v4 installs Node.js on the runner.
      - name: Setup Node.js
        uses: actions/setup-node@v4
        # with: Passes input parameters to the action.
        #   node-version: '20' installs Node.js version 20.x.
        with:
          node-version: '20'

      # run: Executes a shell command directly on the runner.
      #   'npm ci' installs dependencies from package-lock.json.
      #   It's faster and stricter than 'npm install' — preferred in CI
      #   because it ensures exact versions from the lockfile.
      - run: npm ci

      # run: Another shell command — runs the project's test suite.
      #   If any test fails, this step fails, the job fails,
      #   and the workflow reports failure on the PR.
      - run: npm test
💡
Key Takeaway

Every uses: step references a pre-built action (someone else's code). Every run: step executes your own shell commands. You'll use a mix of both in every real workflow. Think of uses: as importing a library and run: as writing your own function.

📋 Event Types Reference

Here are the most commonly used GitHub Actions events, when they fire, and when to use each:

EventTrigger TypeExample ConfigWhen to Use
push Automatic — on git push on: push: branches: [main] Run CI on every commit to a branch. Most common trigger for build + test pipelines.
pull_request Automatic — on PR activity on: pull_request: branches: [main] Run checks before code is merged. Pairs with branch protection to block bad PRs.
schedule Cron-based timer on: schedule: - cron: '0 2 * * 1' Nightly builds, weekly dependency updates, periodic vulnerability scans, stale issue cleanup.
workflow_dispatch Manual — button in UI on: workflow_dispatch: inputs: env: … Manual deployments, ad-hoc tasks, on-demand report generation. Supports custom inputs.
release Automatic — on release publish on: release: types: [published] Publish packages to npm/PyPI, deploy production, build release binaries, create changelogs.
issues Automatic — on issue activity on: issues: types: [opened, labeled] Auto-assign reviewers, add labels, post welcome messages, triage new issues.
⚠️
Important

You can combine multiple events in a single workflow. A workflow with on: [push, pull_request] runs on both — but be careful, a push to a branch that has an open PR will trigger both events, resulting in duplicate runs. Use branch and path filters to avoid this.

⌨️ Hands-on: Build a Multi-Event Workflow

Let's create a workflow that triggers on both push and pull_request, uses the actions/checkout action, and runs a custom step.

Step 1: Create the Workflow File

In your repository, create .github/workflows/fundamentals-lab.yml:

yaml
name: Fundamentals Lab

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  explore:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Show GitHub context
        run: |
          echo "Event:      ${{ github.event_name }}"
          echo "Branch:     ${{ github.ref }}"
          echo "Commit SHA: ${{ github.sha }}"
          echo "Actor:      ${{ github.actor }}"
          echo "Repository: ${{ github.repository }}"
          echo "Run ID:     ${{ github.run_id }}"

      - name: List repository files
        run: |
          echo "--- Repository root ---"
          ls -la
          echo ""
          echo "--- Git log (last 3 commits) ---"
          git log --oneline -3

      - name: Custom build step
        run: |
          echo "Building the project..."
          echo "In a real workflow, this would be: npm ci && npm run build"
          echo "Build complete! ✅"

Step 2: Commit and Push

bash
mkdir -p .github/workflows
# paste the YAML above into .github/workflows/fundamentals-lab.yml

git add .github/workflows/fundamentals-lab.yml
git commit -m "ci: add fundamentals lab workflow"
git push origin main

Step 3: View the Workflow Run

Go to your repository on GitHub and click the Actions tab. You'll see "Fundamentals Lab" in the list. Click into the run to see:

Step 4: Test the Pull Request Trigger

Create a branch, make a change, and open a PR targeting main. The same workflow will trigger again — but this time github.event_name will show pull_request instead of push. This is how the same workflow adapts to different events.

💡
Experiment

Try adding workflow_dispatch: to the on: block (no extra config needed). After pushing, you'll see a "Run workflow" button appear in the Actions tab. Click it to manually trigger the workflow — the context will show workflow_dispatch as the event name.

🐛 Debugging Common Issues

Scenario: "Workflow Never Triggers"

You pushed code but nothing shows up in the Actions tab. Here's the systematic checklist:

Scenario: "Job Shows 'Skipped'"

The workflow triggered but one or more jobs show "Skipped" with a grey icon:

💡
Debugging Tip

Enable debug logging by adding the secret ACTIONS_STEP_DEBUG with value true in your repository settings (Settings → Secrets and variables → Actions). This reveals verbose step-level output including action internals, which is invaluable for troubleshooting.

🎯 Interview Questions

Beginner

Q: What is a GitHub Action?

A GitHub Action is a reusable, pre-built unit of automation that performs a specific task within a workflow. Actions are referenced using the uses: keyword (e.g., uses: actions/checkout@v4). They can be published on the GitHub Marketplace for anyone to use, or stored privately in your own repository. Actions abstract away complex logic — instead of writing 50 lines of shell commands to set up a Node.js environment, you use actions/setup-node@v4 with one line. Actions are versioned (using Git tags like @v4 or commit SHAs) so you can pin to a specific, known-good version.

Q: What is the difference between an event and a trigger in GitHub Actions?

In GitHub Actions, the terms are essentially interchangeable. An event is something that happens in or to your repository — a push, a pull request being opened, a release being published, a cron schedule firing, or an external API call. The event triggers the workflow to run. The on: key in the workflow YAML defines which events trigger it. However, events can have sub-filters (branches, paths, types) that narrow when the trigger actually fires. So an event is the "thing that happened" and the trigger is "the condition that starts the workflow" — practically, they're the same concept in GitHub Actions.

Q: What does runs-on: ubuntu-latest mean?

runs-on specifies the runner — the virtual machine where the job executes. ubuntu-latest means GitHub will provision a fresh Ubuntu Linux VM (currently Ubuntu 22.04 or 24.04, depending on updates) with common development tools pre-installed (Git, Docker, Node.js, Python, Go, .NET, etc.). After the job completes, the VM is destroyed — every run starts from a clean state. Other options include windows-latest, macos-latest, and self-hosted runners. You can also use specific versions like ubuntu-22.04 for reproducibility.

Q: Why do almost all workflows start with actions/checkout?

Because the runner starts as a blank virtual machine with no access to your code. The actions/checkout@v4 action clones your repository into the runner's workspace ($GITHUB_WORKSPACE), making your source code, configuration files, and scripts available to subsequent steps. Without it, commands like npm install or dotnet build would fail because there's no project to build. It also sets up Git credentials so you can make commits from the workflow if needed. The only workflows that skip checkout are those that don't need repository code — e.g., workflows that only call external APIs.

Q: What is the difference between uses: and run: in a step?

run: executes a shell command directly — it's your own code running in bash (Linux/macOS) or PowerShell (Windows). Example: run: npm test. uses: executes a pre-built action — someone else's packaged code that performs a specific task. Example: uses: actions/checkout@v4. A step can have either run: or uses:, but not both. Think of run: as writing code inline and uses: as importing a library function. In practice, most workflows are a mix: a few uses: steps for setup, then run: steps for project-specific commands.

Intermediate

Q: Do jobs run in parallel or sequentially? How do you control this?

Jobs run in parallel by default. If you define three jobs — build, test, deploy — all three start simultaneously on separate runners. To create sequential execution, use the needs: keyword. For example, deploy: needs: [build, test] means the deploy job waits until both build and test complete successfully. If any dependency fails, dependent jobs are skipped (unless you add if: always()). This is powerful because you can mix parallel and sequential: build and lint in parallel, then test after both pass, then deploy after test passes. This pattern minimizes total pipeline time while maintaining correct ordering.

Q: What are GitHub context objects and how are they used?

GitHub context objects provide metadata about the workflow run accessible via the ${{ }} expression syntax. The main contexts are: github.* (repo, event, SHA, ref, actor), env.* (environment variables), secrets.* (encrypted secrets), steps.* (outputs from previous steps), job.* (job status), runner.* (runner OS, temp directory), and matrix.* (current matrix values). Common uses: tagging Docker images with github.sha, conditional deployments with github.ref, referencing secrets with secrets.MY_TOKEN, and passing data between steps with steps.step_id.outputs.name. Context values are evaluated at runtime and injected into the YAML before execution.

Q: What is workflow_dispatch and when would you use it?

workflow_dispatch is an event that adds a "Run workflow" button to the Actions tab in GitHub. It allows manual triggering of workflows with optional custom inputs (text fields, dropdowns, booleans). Use cases: Manual deployments — trigger a production deploy on demand with an environment selector. Ad-hoc tasks — run a database migration, generate a report, or trigger a data sync. Testing — run a specific workflow without having to push a commit. Inputs are defined in the YAML and accessible via github.event.inputs.input_name. It's especially useful when you want automation but with human-initiated control — common in deployment workflows where you want to choose which environment to target.

Q: How do you share data between jobs if each job runs on a separate runner?

Since jobs run on isolated runners, you use artifacts or job outputs. For files (build output, test reports, binaries): use actions/upload-artifact@v4 in the producing job and actions/download-artifact@v4 in the consuming job. For small values (strings, booleans): use job outputs — set an output in a step with echo "name=value" >> $GITHUB_OUTPUT, then reference it in another job with needs.job_id.outputs.name. Artifacts persist for the duration of the workflow run (default 90 days) and can be downloaded from the Actions UI. For large files, consider pushing to an external store (S3, Azure Blob, container registry) and passing the reference as a job output.

Q: What are the security concerns with using third-party actions from the Marketplace?

Third-party actions run code inside your CI environment with access to your secrets, source code, and GITHUB_TOKEN. Risks include: Supply chain attacks — a compromised action could exfiltrate secrets. Tag mutability@v4 is a mutable Git tag; the maintainer could change what it points to. Unmaintained actions — abandoned actions may have unpatched vulnerabilities. Mitigations: Pin to commit SHAuses: actions/checkout@a5ac7e51b41… is immutable. Fork critical actions — maintain your own copy of actions that access secrets. Use Dependabot — it can update action versions and alert on vulnerabilities. Restrict permissions — use permissions: at workflow or job level to limit what GITHUB_TOKEN can do. Review the source — check the action's repository for suspicious code before using it.

Scenario-Based

Q: You need a workflow that runs tests on push, builds a Docker image on merge to main, and deploys on manual trigger. How would you structure this?

Create three separate workflow files for clarity and independent control. ci.yml: on: [push, pull_request] — runs lint and tests. Triggers on every branch. build.yml: on: push: branches: [main] — checks out code, builds Docker image, pushes to registry. Only runs when code is merged to main (not on feature branches). deploy.yml: on: workflow_dispatch: inputs: environment: … — accepts a target environment input, pulls the latest image, deploys to the chosen environment. This separation follows the principle of single responsibility: CI runs on all branches, build runs on main, and deploy is human-initiated. Alternatively, you can combine all three in one file using job conditions: if: github.event_name == 'push' && github.ref == 'refs/heads/main' for build jobs, and if: github.event_name == 'workflow_dispatch' for deploy jobs.

Q: Your workflow has a build job and a deploy job. The deploy job is running even when build fails. What's wrong?

The deploy job likely has if: always() set, which makes it run regardless of dependency status. Or, the deploy job is missing the needs: build keyword — without needs:, jobs run in parallel, so deploy starts immediately without waiting for build. Fix: Ensure deploy has needs: build (or needs: [build, test] if multiple dependencies). Remove if: always() unless you intentionally want it to run after failures (e.g., a cleanup or notification job). If you need a job that runs after failure but not on success, use if: failure() — this is useful for "notify team on failure" jobs.

Q: A teammate pinned an action to @v4 (a tag). You suggest pinning to a commit SHA instead. Why?

Git tags are mutable — the action maintainer can delete and recreate the v4 tag pointing to different code at any time. This means the code your workflow runs could change without any change to your workflow file. In a supply chain attack, a compromised maintainer could push malicious code to the same tag. A commit SHA (e.g., @a5ac7e51b41094c92402da3b24376905380afc29) is immutable — it always points to the exact same code. Pinning to SHA ensures: (1) reproducible builds, (2) protection against tag hijacking, (3) you know exactly what code runs in your CI. The tradeoff is readability and manual updates, which you mitigate by using Dependabot to automatically open PRs when new action versions are available.

Q: How would you debug a workflow where github.ref isn't what you expect in a pull_request event?

This is a common gotcha. On a push event, github.ref is the branch ref (e.g., refs/heads/main). But on a pull_request event, github.ref is the merge ref (e.g., refs/pull/42/merge), not the source branch. If your condition is if: github.ref == 'refs/heads/main', it will never match on PR events — causing jobs to be skipped. Fix: For PR events, use github.base_ref (the target branch, e.g., main) or github.head_ref (the source branch, e.g., feature/my-branch). Or use the event name: if: github.event_name == 'pull_request'. Debug step: Add a step that prints all context values: run: echo "${{ toJSON(github) }}" to see exactly what's available.

Q: Your organization wants to restrict which actions developers can use in workflows. How would you enforce this?

GitHub provides organizational controls at Settings → Actions → General: (1) Allow all actions — no restrictions. (2) Allow select actions — whitelist specific actions by owner (e.g., "actions/*", "docker/*") or individual actions. (3) Disable Actions — no workflows run at all. Best practice for enterprises: allow actions from GitHub (actions/*), allow verified marketplace creators, and maintain an internal allowlist of approved third-party actions. Additionally, require all actions be pinned to commit SHAs (enforced via a linting action like zgosalvez/github-actions-ensure-sha-pinned-actions). For maximum control, fork approved actions into an internal GitHub organization and require teams to use the internal forks — this way you control exactly what code runs in CI.

🌍 Real-World Use Case

A platform engineering team at a fintech company uses workflow_dispatch and schedule events to power two critical workflows:

Manual Deployments with workflow_dispatch

Their production deployment workflow uses workflow_dispatch with inputs for the target environment and the image tag. When a release manager is ready to deploy, they go to the Actions tab, select the deploy workflow, choose "production" from the environment dropdown, and paste the Docker image tag. The workflow then:

This gives them full control over when deployments happen (no accidental deploys on merge) while keeping the process fully automated and auditable. Every deployment is logged in the Actions tab with who triggered it, what inputs were provided, and the full execution log.

Nightly Builds with schedule

Their scheduled workflow runs every night at 2 AM UTC. It:

This combination — manual deploys for safety, nightly builds for thoroughness — gives them confidence that production is stable and that the codebase is continuously validated even when no one is actively committing.

📝 Summary

← Back to GitHub Actions Course