Reading and writing a GitHub Actions workflow
Intermediate GitHub UI
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.
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@v4Line 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 GitLabscriptline.uses:— an action, a reusable step someone else published, with inputs given underwith:.
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
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
- Run the
run:lines yourself. They are ordinary commands. - Let the editor validate the YAML. VS Code and IntelliJ both know the workflow schema.
- Push to a branch and open a pull request. Workflows triggered by
pull_requestrun 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
$ cat .github/workflows/*.yml
$ python tools/build.py && python tools/check-links.pyWith GitHub's tool installed, gh workflow list, gh run list and gh run view --log-failed cover most of what the Actions tab does.
The GitHub Actions extension lists workflows and runs in the sidebar, opens logs in a tab, and validates workflow YAML with completion for action inputs.
Ultimate provides schema completion for workflow files, and the Pull Requests window shows each check with a link to its log.
The same three jobs as .gitlab-ci.yml, with stages instead of needs and no checkout step. See lesson 14.4.
Actions lists workflows on the left and runs on the right. A run shows its jobs; a job shows its steps, each expandable, with a link icon per line for sharing an exact log line. Re-run jobs and Re-run with debug logging are in the run menu.
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
maindirectly, which most rulesets prevent.
Try it yourself
Goal: read a workflow and add a non-blocking check.
- Open
.github/workflows/in your practice repository and list the workflows, their triggers and their jobs. - Note which step does the checkout and what would happen without it.
- On a branch, add a job that checks documentation, with
continue-on-error: true. - Push, open a pull request, and read the check in the Checks tab.
- 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.