Git Course 0%

Reading and writing a GitHub Actions workflow

Intermediate GitHub UI ≈ 14 min

What you will learn

  • Every keyword in a small workflow file
  • What an action is, and why versions are pinned
  • How workflows differ from GitLab pipelines in practice

After this lesson you can

  • I can read my repository's workflows and add a documentation check to one

Why this matters

If your team is on GitHub, this file is where the checks on your pull requests are defined. It looks different from GitLab's but does the same things, and the differences are worth two minutes each.

The real file

This site's own workflow, entire. Same three jobs as the GitLab file: check the committed HTML is current, check the links, publish the site.

.github/workflows/pages.yml
name: Site

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-is-current:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install --quiet markdown
      - run: python tools/build.py
      - name: Fail if the committed HTML is out of date
        run: git diff --exit-code || (echo "Run 'python tools/build.py' and commit the result." && exit 1)

  links:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install --quiet markdown
      - run: python tools/build.py
      - run: python tools/check-links.py

  deploy:
    if: github.ref == 'refs/heads/main'
    needs: [build-is-current, links]
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install --quiet markdown
      - run: python tools/build.py
      - uses: actions/upload-pages-artifact@v3
        with:
          path: .
      - id: deployment
        uses: actions/deploy-pages@v4

Line by line

name is what appears in the Actions tab. The file's own name does not matter; a repository can have many workflow files, each with its own triggers.

on is the trigger list. Here: pushes to main, and every pull request. Other common ones are schedule with a cron expression, workflow_dispatch for a manual run button, and release for publishing.

jobs contains the jobs, and each has a name you choose.

runs-on is the runner's operating system: ubuntu-latest, macos-latest, windows-latest, or a self-hosted label.

steps is the list of things the job does. A step is either:

  • run: — a shell command, exactly like a GitLab script line.
  • uses: — an action, a reusable step someone else published, with inputs given under with:.

actions/checkout@v4 is the one every job needs: it clones the repository into the runner. GitLab does this automatically; GitHub does not, and a job that mysteriously cannot find any files is almost always missing this step.

needs: expresses order. GitLab has stages; GitHub has dependencies between jobs, so needs: [build-is-current, links] makes deploy wait for both, and everything without a needs runs in parallel.

if: limits when a job runs, the equivalent of GitLab's rules.

permissions: grants the automatic GITHUB_TOKEN only what this job needs. Pages deployment needs pages: write and id-token: write; most jobs need nothing extra.

environment: records the job as a deployment to a named environment, which is what puts the site's URL on the repository page (lesson 14.6).

Actions, and why the version is pinned

actions/checkout@v4 means "the action published at github.com/actions/checkout, major version 4". The @v4 matters: without it there is no telling what the step will do next month. Repositories with strict security requirements pin to a full commit hash instead of a tag, because a tag can be moved.

Actions are the biggest practical difference from GitLab. There is a large public catalogue, so many jobs are three lines of uses: rather than twenty lines of shell. The trade is that each one is third-party code running with access to your repository, which is why organizations often restrict which actions are allowed.

GitLab and GitHub, side by side

GitLab GitHub
stages and stage: needs: between jobs
script: steps: with run:
image: runs-on:, plus container: if you want a specific image
Repository cloned automatically actions/checkout@v4 as the first step
rules: on: and if:
artifacts: actions/upload-artifact
CI/CD variables Secrets and variables under Settings
CI_JOB_TOKEN GITHUB_TOKEN, with permissions:

Adding a check of your own

YAML
  spelling:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
      - run: npx --yes cspell "docs/**/*.md"

continue-on-error: true is the equivalent of GitLab's allow_failure: the check reports without blocking, which is the right way to introduce one to a team.

Testing a change before pushing

  1. Run the run: lines yourself. They are ordinary commands.
  2. Let the editor validate the YAML. VS Code and IntelliJ both know the workflow schema.
  3. Push to a branch and open a pull request. Workflows triggered by pull_request run the version on your branch, so you see your change immediately.

One caveat worth knowing: a workflow triggered by pull_request from a fork does not receive secrets, so jobs needing them are skipped or fail on outside contributions. That is a deliberate protection, not a misconfiguration (lesson 10.12).

How to do it

Terminal
$ cat .github/workflows/*.yml
$ python tools/build.py && python tools/check-links.py

With GitHub's tool installed, gh workflow list, gh run list and gh run view --log-failed cover most of what the Actions tab does.

Common mistakes

  • Forgetting actions/checkout, so the job runs against an empty directory.
  • Not pinning an action's version.
  • Expecting stages. Order comes from needs:.
  • Assuming secrets are available on fork pull requests. They are not.
  • A new check added as blocking before the existing problems are cleared.
  • Editing a workflow on main directly, which most rulesets prevent.

Try it yourself

Goal: read a workflow and add a non-blocking check.

  1. Open .github/workflows/ in your practice repository and list the workflows, their triggers and their jobs.
  2. Note which step does the checkout and what would happen without it.
  3. On a branch, add a job that checks documentation, with continue-on-error: true.
  4. Push, open a pull request, and read the check in the Checks tab.
  5. Run the same command locally and confirm the results match.

Expected result: a new check visible on the pull request, reporting without blocking, and the same result reproducible on your machine.

Show solution

Step 2 is the difference from GitLab worth remembering. GitLab clones your repository into every job automatically; GitHub does not, and actions/checkout is the step that does it. A job that cannot find any files is almost always missing that line.

Check yourself

1. What does actions/checkout@v4 do, and why is it in nearly every job?
2. How is job order expressed in a GitHub workflow?
3. A pull request from a fork skips a job that uses a secret. Why?

Key terms

Pipeline Artifact Environment variable Continuous integration (CI)