GitHub Actions from scratch: workflows, jobs, steps and triggers
The first time I opened a repository's .github/workflows directory I closed it again. It was a wall of YAML with keys I did not recognise, nested four deep, and every tutorial I found started by pasting a finished eighty-line file and explaining it top to bottom. That is the wrong order. Top to bottom is how the file is written, not how it is understood.
There are only four ideas in a workflow file, and they stack: an event happens, which starts a workflow, which contains jobs, which contain steps. Everything else is detail hanging off one of those four. If you learn them in that order you can write a real pipeline by the end of this article, and you will understand why almost every deployment example you find online is triggered the wrong way.
This is the second article in a series about getting code onto a client's Frappe server without holding credentials to it. If you want to see where all this is heading, the finished implementation is in the walkthrough on deploying Frappe apps to a client server you are not allowed to touch. You do not need it yet. You need this first.
a workflow is a YAML file in .github/workflows/ that says "when this event happens, run these jobs". Learn on:, jobs:, steps: and runs-on: and you have the whole language.
- Jobs run in parallel by default and each gets a clean machine. Steps run in order and share one machine.
uses:runs somebody else's action.run:runs your shell command. That is the entire distinction.- Do not deploy on
pushtomain. Deploy on a tag, so that "what is in production" is a name you chose, not whatever landed last. runs-on:decides which machine executes the job, and that one key is where the interesting part of this series lives.
The whole file, before the explanation
Here is a complete, working workflow. Twelve lines. It runs a deploy script on a machine when you push a version tag.
Save that as .github/workflows/deploy.yml, commit it, push. Nothing happens, which is correct - you have not pushed a tag yet. That is already the most common "my workflow does not run" support question answered.
The on: block, and why push to main is wrong for deployment
The on: block is a list of events that start the workflow. The three that matter in practice:
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
That says: run when someone pushes to main, run when someone opens or updates a pull request targeting main, and run when someone clicks a button in the Actions tab. For a test suite, that combination is exactly right and you should use it.
For a deployment it is wrong, and this is the single most repeated mistake in CI examples on the internet.
If deployment triggers on every push to main, then "what is running in production" is defined as "whatever the last person merged". Nobody decided to release it. There is no name for it. When the client rings you on a Friday to say something changed, you have to reconstruct which merge did it from commit timestamps. And the rollback question - "put back what we had this morning" - has no clean answer, because this morning was not a thing, it was a commit hash somebody would have to go and find.
A tag fixes all of that, because a tag is a decision:
on:
push:
tags:
- 'v*'
Now merging to main is safe and boring. Releasing is a separate, deliberate act:
git tag -a v1.4.0 -m "Add supplier ageing report"
git push origin v1.4.0
Note the second line. Tags are not sent by a normal git push. You push the tag explicitly, or with git push --tags. If your tag-triggered workflow never fires, this is almost always why.
There is a subtlety worth knowing: 'v*' is a glob, not a regular expression, and it is greedy in a way people do not expect. It matches v1.4.0, and it also matches v2-experiment and vendor-test. If that bothers you, be explicit:
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
That is GitHub's filter pattern syntax, which supports character ranges and +, and it will only match a proper three-part version.
workflow_dispatch, the button
workflow_dispatch adds a "Run workflow" button in the Actions tab. It costs one line and I now put it on every deployment workflow I write, because the day you need to redeploy the current release without inventing a new version number, you will want it.
It also takes inputs, which turns a workflow into a small internal tool:
on:
workflow_dispatch:
inputs:
ref:
description: Tag or branch to deploy
required: true
default: main
skip_backup:
description: Skip the pre-migrate backup
type: boolean
default: false
Inside the workflow those arrive as github.event.inputs.ref. Be careful with the boolean: it reaches your shell as the string "true" or "false", not as a real boolean, and "false" is a non-empty string. Comparing it in a bash if without thinking is a good way to skip a backup you meant to take.
Jobs and steps are not the same shape
This is where most beginners lose a day, so it is worth being precise. Jobs and steps look similar in the YAML. They behave completely differently.
Two consequences follow, and they explain most confusing CI behaviour:
Jobs do not share a filesystem. If a build job produces a wheel and a deploy job needs it, the deploy job cannot just read it off disk. Either upload it as an artifact and download it, or merge the two jobs into one. Beginners lose hours to this, because the YAML makes the jobs look adjacent when the machines are not.
Jobs run at the same time unless you say otherwise. Adding needs: creates an ordering:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: python -m pytest
deploy:
needs: test
runs-on: [self-hosted, frappe]
steps:
- uses: actions/checkout@v5
- run: ./deploy.sh
Now deploy waits for test, and if the tests fail it never starts. That single word is the whole reason to keep tests and deployment in one workflow rather than two.
uses versus run
A step does exactly one of two things.
uses: runs a packaged action written by somebody else, referenced as owner/repo@ref. It usually takes parameters through with::
- uses: actions/checkout@v5
with:
fetch-depth: 0
run: runs shell commands on the machine, and a pipe gives you several lines:
- name: Deploy the release
run: |
set -euo pipefail
cd /home/frappe/frappe-bench
./deploy.sh
env:
SITE: dev.client.local
Two habits worth forming immediately. Pin third-party actions to a version you chose, not to a moving branch, because @main means "whatever that repository contains at the moment my deploy runs". And start every multi-line run: with set -euo pipefail. Without it, a failing command in the middle of your block is ignored and the step still reports success - which on a deployment means a broken deploy that says it worked.
runs-on, and the question this series is actually about
Which leaves one key.
runs-on: ubuntu-latest
That says: give me a fresh virtual machine in Microsoft's cloud, with Ubuntu on it, run my steps, then destroy it. It is genuinely excellent for tests. It is also, for our purposes, the problem, because that machine is in a data centre somewhere and your client's server is in an office in Westlands behind a firewall with no inbound ports open. A machine in Azure cannot reach it, and getting it to reach it means opening a hole in the client's network, which is precisely the conversation we are trying to avoid.
But runs-on can say something else:
runs-on: [self-hosted, frappe]
That is the same key, doing the same job of selecting a machine. The machine it selects can be the client's own server. Nothing in the workflow file changes shape, and yet the entire network direction of the deployment reverses.
How that is possible - and why it means no inbound connection is ever needed - is the subject of the next article on what a runner actually is. It is the single idea this whole series rests on. Once it clicks, the rest is scripting.
If you want a worked example on infrastructure you own before trying any of this on a client's, shipping to your own VPS with GitHub Actions is the same machinery at a smaller stake.
Before you go there, write a workflow. Make a repository, add .github/workflows/ci.yml with on: push, one job, one step that runs echo hello, and push it. Watching it turn green in the Actions tab teaches you more than another thousand words here would.
Frequently asked questions
- Where exactly does the workflow file go?
- In .github/workflows/ at the root of your repository, with a .yml or .yaml extension. The directory name is fixed and case sensitive. GitHub picks the file up as soon as it lands on any branch it is configured to watch, so the first push that creates the file can also be the push that runs it.
- Why does my workflow not run at all?
- Almost always one of three things: the file is not under .github/workflows, the on: block does not match the event you produced, or the workflow lives on a branch that the event did not touch. For tag triggers specifically, remember that pushing a tag requires git push origin v1.0.0 or git push --tags. A normal git push does not send tags.
- Do jobs share files with each other?
- No. Each job runs on a fresh machine with its own filesystem, so anything job A writes is gone by the time job B starts. If job B needs job A's output, either upload and download an artifact, or put both pieces of work in the same job as consecutive steps.
- Should I use actions/checkout@v4 or @v5?
- Use v5. GitHub forced JavaScript actions onto Node 24 in June 2026 and removes Node 20 entirely in September 2026, and v5 is the version built for Node 24. On a self-hosted runner this matters more than on a hosted one, because v5 needs runner version 2.327.1 or newer, and a runner you installed a year ago and never touched will fail.