GitHub Actions YAML syntax is surprisingly flexible, allowing you to define complex workflows with a surprising amount of control, but most people don’t realize how much of the actual execution is dictated by implicit behaviors rather than explicit configuration.

Let’s see this in action. Imagine you have a simple workflow that checks out your code and then runs a script.

name: Simple Workflow

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Run script
        run: echo "Hello from GitHub Actions!"

When this workflow runs, GitHub Actions orchestrates the entire process. The on: [push] triggers the workflow on any push event to your repository. The jobs section defines one or more jobs that will run. In this case, we have a single job named build. runs-on: ubuntu-latest specifies that this job will execute on a fresh Ubuntu virtual machine hosted by GitHub.

The steps are the individual tasks within a job. The uses: actions/checkout@v3 step is a pre-built action that handles checking out your repository’s code into the runner’s filesystem. This is crucial because subsequent steps need access to your code. The run: echo "Hello from GitHub Actions!" step then executes a shell command. The output of this command will be displayed in the Actions run logs.

The mental model here is that of a distributed system where GitHub manages the runners (the virtual machines) and your YAML defines the recipe for what should happen on those runners. Each step is an atomic unit of work. Actions, like actions/checkout, are reusable components that encapsulate common tasks, saving you from writing boilerplate code. The run keyword is for executing arbitrary shell commands.

You can control a lot here. For instance, you can specify dependencies between jobs using needs, pass data between jobs using outputs and inputs, and even define environments with secrets and protection rules. The env keyword allows you to set environment variables for steps or jobs, which can be used by your scripts or actions.

Consider how you might pass a version number from one job to another.

name: Versioned Build

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:

      version: ${{ steps.tag.outputs.tag }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Get version tag
        id: tag # This ID is crucial for referencing outputs
        run: echo "tag=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy version

        run: echo "Deploying version ${{ needs.build.outputs.version }}"

Here, the build job has an outputs section that captures the tag output from the step with id: tag. The git describe --tags --abbrev=0 command is used to get the most recent tag. The >> $GITHUB_OUTPUT syntax is the standard way to write outputs that can be consumed by other steps or jobs. The deploy job then uses needs: build to ensure it runs only after build completes successfully, and it accesses the version output from the build job using ${{ needs.build.outputs.version }}.

What most people don’t realize is that the GITHUB_OUTPUT environment variable, which is used to set step outputs, is dynamically managed by the Actions runner. When you write to $GITHUB_OUTPUT, you’re not just writing to a file; you’re interacting with a specific mechanism that the runner parses to make those outputs available. This means that the order of steps and how you format the output are critical, and it’s not just simple file I/O.

The next concept you’ll likely encounter is managing secrets and sensitive data within your workflows.

Want structured learning?

Take the full Github-actions course →