GitHub Actions Basics: Workflows, Jobs, and Steps

1. Overview

GitHub Actions is a CI/CD tool built into GitHub that helps you automate building, testing, and deploying without needing a separate CI server. This article explains three core concepts: workflow, job, and step, with concrete examples for a Spring Boot project using Maven.

After reading this, you’ll be able to write a workflow file that runs tests automatically when you push code, understand how jobs run in parallel or sequentially, and know how to debug when a workflow fails.

2. What is a Workflow?

A workflow is a YAML file located in the .github/workflows/ directory of your repository. Each workflow defines when to run (trigger) and what to do (jobs).

# .github/workflows/ci.yml
name: CI Pipeline

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - name: Build with Maven
        run: mvn -B package

Explanation:

  • name at the top of the file is the display name in the Actions tab of GitHub.
  • on declares the trigger: here the workflow runs when there’s a push or pull_request to the main branch.
  • jobs.build is a single job named build, running on the ubuntu-latest virtual machine.

Note: The file must be in the correct path .github/workflows/*.yml; if you put it in the wrong directory, GitHub won’t recognize the workflow.

3. Job: Independent Execution Unit

A workflow can have multiple jobs. Each job runs on its own runner (virtual machine), and by default jobs run in parallel unless you declare needs.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: mvn -B test

  deploy:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - run: echo "Deploying application..."

Explanation:

  • test and deploy are two separate jobs, each with a clean runner environment that don’t share file systems with each other.
  • needs: test forces deploy to wait for test to succeed before running — creating a sequential relationship.
  • If there’s no needs, GitHub Actions will run test and deploy at the same time.

3.1. Sharing Data Between Jobs

Since each job has its own runner, to pass files from one job to another you need to use artifacts.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: mvn -B package
      - uses: actions/upload-artifact@v4
        with:
          name: app-jar
          path: target/*.jar

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: app-jar

Explanation:

  • upload-artifact saves the .jar file from the build job to temporary GitHub storage.
  • download-artifact in the deploy job retrieves that file back to the new runner.
  • This is the only way to pass data between jobs (you can’t use regular environment variables).

4. Step: Smallest Unit of Action

A step is a specific action within a job, running sequentially from top to bottom. Steps have two main types: using uses (calling a pre-built action) or using run (running a shell command).

steps:
  - name: Checkout code
    uses: actions/checkout@v4

  - name: Cache Maven dependencies
    uses: actions/cache@v4
    with:
      path: ~/.m2/repository
      key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}

  - name: Run tests
    run: mvn -B test

  - name: Print result
    run: echo "Test step finished with status ${{ job.status }}"

Explanation:

  • The Checkout code step uses the built-in actions/checkout action to fetch source code to the runner.
  • The Cache Maven dependencies step caches the ~/.m2/repository directory, making subsequent builds faster since dependencies don’t need to be re-downloaded.
  • The cache key is based on the hash of pom.xml — when pom.xml changes, the old cache is invalidated and a new cache is created.
  • The last step uses run to execute a regular shell command, and can reference context like ${{ job.status }}.

5. Environment Variables and Secrets

You often need to pass sensitive config (API keys, tokens) to your workflow without hard-coding them in the YAML file.

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      SPRING_PROFILES_ACTIVE: prod
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to server
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying with profile $SPRING_PROFILES_ACTIVE"
          curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/deploy

Explanation:

  • env at the job level applies to all steps in that job (SPRING_PROFILES_ACTIVE).
  • env at the step level applies only to that specific step (DEPLOY_TOKEN).
  • secrets.DEPLOY_TOKEN is retrieved from Settings → Secrets and variables → Actions of your repository, and the actual value never appears in the logs.

Note: Never directly print a secret value using echo $DEPLOY_TOKEN in your logs — GitHub will automatically mask it, but it’s best to avoid printing secrets altogether.

6. Matrix: Running Multiple Versions at Once

When you need to test on multiple Java versions or operating systems, matrix helps you avoid writing repetitive jobs.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java: [17, 21]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: 'temurin'
      - run: mvn -B test

Explanation:

  • strategy.matrix.java declares a list of values [17, 21].
  • GitHub Actions automatically creates 2 jobs running in parallel, each running with a corresponding Java version.
  • This approach saves you from writing repetitive YAML, and is ideal when you need to ensure your code works well across multiple environments.

7. Testing Workflow with a Simple Test

You don’t test workflows directly with JUnit, but you should ensure that the mvn test step in your workflow actually runs your project’s test suite correctly. Here’s a simple test for an OrderService that CI will run:

class OrderServiceTest {

    private final OrderService orderService = new OrderService();

    @Test
    void shouldCalculateTotalPriceCorrectly() {
        Order order = new Order("ORD-001", List.of(
                new OrderItem("Keyboard", 2, BigDecimal.valueOf(50)),
                new OrderItem("Mouse", 1, BigDecimal.valueOf(20))
        ));

        BigDecimal total = orderService.calculateTotal(order);

        assertThat(total).isEqualByComparingTo(BigDecimal.valueOf(120));
    }
}

Explanation:

  • This test is in src/test/java, and will be run automatically by mvn -B test in the workflow step.
  • If the test fails, the Run tests step reports an error (non-zero exit code), the job stops, and dependent jobs (needs) won’t run.
  • This is why CI is useful: buggy code is caught before it’s merged into main.

Result when running on GitHub Actions UI (Actions tab):

✓ Checkout code
✓ Set up JDK 21
✓ Build with Maven
✓ Run tests (shouldCalculateTotalPriceCorrectly PASSED)

8. Common Mistakes

  • Wrong YAML indentation: GitHub Actions uses YAML, and incorrect indentation (off by even one space) causes jobs or steps to not be recognized, usually giving an “Invalid workflow file” error.
  • Missing runs-on: each job must have runs-on; omitting it causes the workflow to fail immediately during parsing.
  • Using job environment variables in another job: env and variable outputs don’t automatically carry over between jobs; you need to use outputs or artifact.
  • Cache key doesn’t change when dependencies change: if your cache key is too generic (like just maven-cache), you might accidentally use a stale cache containing outdated dependencies.
  • Running heavy workflows on every push: if you don’t restrict with paths or branches, even tiny commits (like fixing README) will trigger the entire pipeline of build + test, wasting your free Actions minutes (which are limited).

Note: If your project only needs simple builds and doesn’t have many environments, you don’t need to use matrix or artifact — adding unnecessary complexity will make your workflow harder to maintain.

9. Conclusion

A workflow is a YAML file that defines an entire pipeline, a job is an independent execution block running on its own runner, and a step is a specific action within a job. Understanding these three levels clearly helps you organize your CI/CD pipeline: which jobs run in parallel, which jobs need to wait for others via needs, and when to use artifact for data sharing.

When starting out, keep your workflow simple (one job, a few basic steps: checkout, setup Java, test). As your project grows, add matrix, cache, and separate build/test/deploy into different jobs for easier management and debugging.

github-actionsci-cddevopsjavaautomation