</>CodeWithKarani

GitHub Actions from scratch: workflows, jobs, steps and triggers

Karani GeoffreyKarani Geoffrey8 min read

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 push to main. 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.

An annotated GitHub Actions workflow file A twelve line YAML workflow shown on the left, with leader lines running to plain-language explanations on the right. The name key is a label shown in the Actions tab. The on key is the trigger. The tags filter restricts it to version tags. The jobs key opens the list of jobs. The runs-on key selects which machine executes the job. The steps key holds the ordered list of things the job does. .github/workflows/deploy.yml name: Deploy on: push: tags: - 'v*' jobs: deploy: runs-on: [self-hosted, frappe] steps: - uses: actions/checkout@v5 - run: ./deploy.sh A label, nothing more What you will see in the Actions tab The trigger Nothing runs unless something here matches Only version tags, not every push v1.0.0 fires this. A commit to main does not. One or more jobs Parallel by default, each on its own machine Which machine runs this The key this whole series turns on The ordered list of work uses: someone else's action. run: your shell. Every workflow you will ever read is this shape with more lines. Find these five keys first, then read the rest.
Five keys carry the meaning. Everything else in a real workflow file hangs off one of them.

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.

Event, workflow, job and step as nested containers A nesting diagram. An event on the left points into a workflow container. The workflow contains two job containers side by side, one running on a GitHub-hosted machine and one on a self-hosted machine. Each job contains an ordered list of steps. A note records that jobs run in parallel on separate machines unless one declares that it needs another, while steps always run in order on one machine. Event tag v1.4.0 pushed Workflow deploy.yml Job: test runs-on: ubuntu-latest Step 1: check out the code Step 2: install dependencies Step 3: run the test suite One machine, three steps, strictly in order Job: deploy runs-on: [self-hosted, frappe] Step 1: check out the code Step 2: run the deploy script needs: test, so it waits A different machine entirely Jobs are separate machines and run in parallel unless one declares needs. Steps are one machine and always run in order.
Nothing crosses a job boundary except artifacts and outputs you explicitly pass. A file written in the left box does not exist in the right one.

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.
#GitHub Actions#CI/CD#Deployment#DevOps#git
Keep reading

Related articles