GitHub Actions
GitHub Actions1 is GitHub's built-in automation: a YAML file in your repository says when something should run (a push, a pull request, a schedule) and what runs (build, test, publish an image, deploy). In this course it is the shortest path from a commit to a running microservice: every push builds the Maven project, runs the tests, builds the Docker image and, on main, rolls it out to Kubernetes.
This page answers three questions: how a workflow is put together, what decides how long a pipeline takes, and what runs when a job fails. It closes with the security basics (secrets, GITHUB_TOKEN, pinning) and a comparison with Jenkins.
How it works
Workflows are event-driven. The workflow file lives in .github/workflows/, so it is versioned with your code and reviewed in the same pull requests.
flowchart LR
event(["Event<br/>e.g. push to main"]) --> workflow["Workflow<br/>.github/workflows/*.yml"]:::comp
workflow --> jobs["Jobs<br/>parallel by default,<br/>ordered by needs"]:::comp
jobs --> steps["Steps<br/>run: a shell command<br/>uses: an action"]:::tech
steps -.->|"each job executes on"| runner["Runner<br/>a fresh GitHub-hosted VM<br/>or your own machine"] | Concept | Description |
|---|---|
| Workflow | An automated process defined in a YAML file. A repository can have many workflows. |
| Event | What starts a workflow: push, pull_request, schedule, workflow_dispatch (a button), workflow_run (another workflow finished)… |
| Job | A set of steps that run on the same runner. Jobs run in parallel unless needs orders them. |
| Step | One task: a shell command (run) or a reusable action (uses). The steps of a job share the runner's file system. |
| Action | A reusable unit of automation, usually from the GitHub Marketplace (e.g. actions/checkout). |
| Runner | The machine that executes a job (ubuntu-latest, windows-latest, macos-latest, or your own). A GitHub-hosted runner is a fresh VM for every job. |
A first workflow: CI for a Spring Boot microservice
The workflow below runs on every push to main and on every pull request. One job checks out the code, sets up Java, runs the tests, packages the jar, and builds and pushes the Docker image.
permissions: contents: readgives the job'sGITHUB_TOKENonly what it needs (see Secrets andGITHUB_TOKEN).cache: mavenrestores~/.m2/repositoryfrom earlier runs (see Caching).- The image is tagged with the commit SHA (
github.sha), so every image points back to the exact code that built it. - The push step runs only on
pushevents: a pull request builds and tests, but publishes nothing.
Jobs, needs and the critical path
One job is easy to read but slow: every step waits for the previous one. Splitting the work into jobs lets independent work run on separate runners at the same time, and needs declares the order where it matters. The jobs form a DAG (directed acyclic graph):
The checkout and setup steps are left out to show the shape. Two rules decide everything that follows2:
- A job starts when every job in its
needshas finished successfully. Jobs with noneedsstart at once, each on its own runner. - When a needed job fails or is skipped, every job that needs it, directly or further down the chain, is skipped, unless it has a condition such as
if: always()orif: failure(). Jobs that do not need the failed one keep running.
Try it
Press play with the defaults: the run takes 14 min of wall-clock time but 18 runner minutes, because the three middle jobs run side by side. The outlined bars are the critical path, build → integration tests → image → deploy. Now run the unit tests on 3 Java versions: still 14 min, but 24 runner minutes — a matrix costs minutes, not time, as long as runners are free. Turn on cache dependencies: the build is on the critical path, so the run drops to 11 min (and 21 runner minutes). Finally, fail the unit tests: lint and integration tests still run to the end, image and deploy are skipped, and the run stops at 8 min after 18 runner minutes.
What the simulator shows:
- Wall-clock time is the longest path through the DAG (the critical path), not the sum of the jobs. To make a pipeline faster, shorten a job on that path, or split it: speeding up lint here changes nothing.
- Runner minutes are the sum of every job. That is what a private repository pays for: GitHub bills each job rounded up to the whole minute3. Parallel jobs trade minutes for time.
- Real runs also spend time queuing for a runner and setting up each job (a fresh VM, checkout, Java). Many tiny jobs pay that overhead many times.
- Each job starts on a clean machine: files do not travel between jobs. Pass build outputs with the artifact actions (
actions/upload-artifact/actions/download-artifact), or rebuild them.
Matrix
A matrix runs the same job once per combination of values, in parallel. A typical use is testing on several Java versions or operating systems:
| unit tests on three Java versions | |
|---|---|
- Each combination is a separate job, with its own runner and its own minutes; a job that
needs: unit-testswaits for all of them. fail-fastdefaults totrue: when one combination fails, GitHub cancels the ones still in progress or queued. Set it tofalseto see every failure.max-parallelcaps how many combinations run at once; a matrix generates at most 256 jobs per run2.
Caching
Every job starts on a fresh VM, so without a cache Maven downloads all the dependencies on every run. A cache saves a directory under a key and restores it in later runs:
actions/setup-javawithcache: mavencaches~/.m2/repository, keyed on a hash of yourpom.xmlfiles: change a dependency and the key changes.- For anything else,
actions/cachetakes apathand akeyyou build yourself, e.g. with${{ hashFiles('**/package-lock.json') }}. - A cache is a speed-up, never a source of truth: the build must still work on a cache miss.
Secrets and GITHUB_TOKEN
Two different credentials reach a workflow:
| Secrets | GITHUB_TOKEN | |
|---|---|---|
| What | Values you store: DOCKERHUB_TOKEN, KUBECONFIG, API keys | A token GitHub creates automatically for each job |
| Where | Settings → Secrets and variables → Actions (repository, environment or organization) | Nothing to configure: ${{ secrets.GITHUB_TOKEN }} or ${{ github.token }} |
| Reaches | Other systems (Docker Hub, the cluster, a cloud) | This repository only: checkout, PR comments, releases, GitHub Packages |
| Lifetime | Until you rotate it | Expires when the job finishes |
| Scope | Whoever can change a workflow can use it | The permissions: key, per workflow or per job |
- Secrets are redacted from the logs, and they are not passed to workflows triggered from a fork (
GITHUB_TOKENis the exception)4. Still, anyone with write access can write a workflow that uses them: treat write access as secret access. - Declare
permissions:explicitly and grant the least you need: once you list one permission, every permission you do not list is set tonone2. Raise it only in the job that needs it:
| least privilege | |
|---|---|
Pinning actions by SHA
uses: actions/checkout@v7 points to a tag, and a tag can be moved: if the action's repository is compromised, the next run executes whatever v7 points to now, with your secrets within reach. GitHub's security guide recommends pinning actions to a full-length commit SHA, which cannot change5:
| pinned to a commit | |
|---|---|
- Keep the version in a comment, so a person (or Dependabot, with the
github-actionsecosystem) can tell what it is and bump it. - Check that the SHA comes from the action's own repository, not from a fork:
git ls-remote --tags https://github.com/actions/checkout. - The same guide also describes workflow dependency lockfiles, which record the exact commit of every action (including the actions an action uses) and supersede SHA pinning for repositories that adopt them5.
CD: deploy to Kubernetes after a successful CI run
This second workflow starts when the CI workflow completes on main, and deploys only if CI succeeded.
Which commit?
In a workflow started by workflow_run, github.sha is the last commit on the default branch, not necessarily the commit CI built6. When two pushes land close together, it can name an image that does not exist yet. Use github.event.workflow_run.head_sha, the commit of the CI run that triggered this one.
GitHub Actions vs Jenkins
Both tools implement the same CI/CD concepts, with different trade-offs:
| Aspect | GitHub Actions | Jenkins |
|---|---|---|
| Setup | Zero — runs in GitHub's cloud | Requires a server (Docker, VM, or bare metal) |
| Configuration | YAML in .github/workflows/ | Groovy Jenkinsfile |
| Scaling | Managed by GitHub | You manage the agent fleet |
| Reuse | Actions from the GitHub Marketplace | Plugins from the Jenkins update center |
| Cost | Free for standard runners in public repos; private repos get a monthly allowance, then pay per minute3 | Free and open source; you pay for the infrastructure |
| Self-hosted runners | Supported | Native — Jenkins is the server |
| Best for | New projects, open source, GitHub-centric teams | On-premise environments, complex pipelines, code on several hosts |
Key takeaways
- A workflow is event → jobs → steps, versioned in
.github/workflows/; each job runs on a fresh runner. - Jobs run in parallel unless
needsorders them. The wall-clock time is the critical path of that DAG and the runner minutes are the sum of all jobs: speed up the path, not the side jobs. - When a job fails, the jobs that need it are skipped, while independent jobs still run;
if: always()orif: failure()changes that on purpose. - A matrix multiplies jobs (and minutes), not time; a cache removes repeated downloads, and pays the most on the critical path.
- Secrets reach other systems;
GITHUB_TOKENreaches this repository and is scoped bypermissions:— grant the least. Pin actions to a commit SHA.
-
Workflow syntax for GitHub Actions:
needs,strategy.fail-fast,max-parallel,permissions. ↩↩↩ -
GitHub Actions runner pricing and GitHub Actions billing. ↩↩