Skip to content

What Is GitHub Actions? A Complete CI/CD Guide

Lesson 1 of 11Beginner10 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions documentation and api.github.com, August 2026

GitHub Actions is a platform for running code in response to things that happen in a repository.

That is deliberately broader than “CI/CD”. Continuous integration is the most common use and it is one use among many: Actions also labels issues, publishes releases, runs scheduled maintenance, enforces policy, and deploys infrastructure. The primitive is event → code, and CI is one shape it takes.

From event to execution

A vertical chain: an event occurs in the repository, GitHub matches it against workflow files, a workflow run is created containing jobs, each job is assigned a runner, and the runner executes the job's steps, which are either shell commands or actions.

Eventpush, pull_request, schedule, release…WorkflowA YAML file whose `on:` matches the eventWorkflow runOne execution, with its own logsJobsParallel by default; `needs:` creates orderRunnerA machine assigned per jobStepsExecuted in order within the jobCommand or actionA shell line, or reusable code

Each layer has a job of its own, and knowing which one a problem belongs to is most of debugging.

An event is something that happened: a push, a pull request opening, a release publishing, a cron schedule firing, a person clicking a button. GitHub emits these regardless of whether anything is listening.

A workflow is a YAML file in .github/workflows/. Its on: block declares which events it responds to. A repository can have many, and one event can start several.

A workflow run is one execution. It has an ID, logs, a conclusion, and it appears in the Actions tab and in gh run list.

A job is a unit of work that gets its own runner. Jobs in a workflow run in parallel by default — a fact that surprises people, and which needs: exists to constrain.

A runner is the machine. GitHub-hosted runners are fresh virtual machines created per job and destroyed afterwards. Self-hosted runners are machines you provide.

A step is one thing inside a job: either run: (a shell command) or uses: (an action).

An action is reusable code a step invokes. actions/checkout clones your repository; actions/setup-python installs Python. Actions are the reuse mechanism, and they are also somebody else’s code running in your job.

name: CI
on:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "Testing commit $GITHUB_SHA"

Line by line:

LineMeaning
name:What the workflow is called in the interface
on:Which events start it — pushes to main
permissions:What GITHUB_TOKEN may do — read the repository, nothing more
jobs:The jobs this workflow contains
test:The job’s ID, used by needs: and as its status check name
runs-on:Which runner — a GitHub-hosted Ubuntu machine
steps:What the job does, in order
uses:Run an action — here, clone the repository
run:Run a shell command on the runner

$GITHUB_SHA is one of the default environment variables GitHub sets on every runner. There are many, and they are how a workflow learns what triggered it.

CI is the majority case and it is worth seeing the range, because the platform is more general than its reputation.

Continuous integration. Build, lint, test and scan on every pull request. Covered in the CI cluster.

Continuous delivery and deployment. Package an artifact, deploy it to an environment, promote it to production. The CD cluster.

Repository automation. Labelling issues by content, welcoming first-time contributors, closing stale pull requests, syncing files across repositories. This overlaps with what GitHub Apps do, and the choice between them is discussed below.

Scheduled maintenance. Dependency audits, link checks, backups, reports. A schedule: trigger plus a script.

Release automation. Building artefacts on a tag, generating notes, publishing a Release, attaching binaries and provenance.

Policy enforcement. Validating that a changed file meets a standard, that a commit message matches a convention, that a required label is present.

A job needs a machine, and the two kinds differ in more than who pays.

GitHub-hosted runners are ephemeral virtual machines. GitHub creates one per job from a maintained image, runs the job, and destroys it. Nothing survives; nothing is shared between jobs.

Current labels span several operating systems and both architectures:

runs-on: ubuntu-latest # Linux x64
runs-on: ubuntu-24.04-arm # Linux ARM64
runs-on: windows-latest # Windows x64
runs-on: windows-11-arm # Windows ARM64
runs-on: macos-latest # macOS on Apple silicon

Self-hosted runners are machines you provide, register and maintain. They exist for three real reasons: hardware GitHub does not offer, software licensing that cannot move, and access to a private network. They are covered in Self-Hosted Runners and Secure Self-Hosted Runners, and the security responsibility they carry is substantial.

Do not self-host to reduce your bill. Self-host when hosted runners cannot do the job.

Three comparisons come up constantly, and each clarifies something.

Jenkins is a server you run. That is the whole difference and it propagates everywhere.

GitHub ActionsJenkins
InfrastructureManaged, or your runnersYours, always
ConfigurationYAML in the repositoryJobs, or a Jenkinsfile
Runner lifecycleEphemeral by defaultUsually persistent agents
ReuseActions, reusable workflowsPlugins, shared libraries
Access controlRepository and organisationIts own model
Upgrades and patchingGitHubYou

Jenkins wins where you need total control, unusual hardware, or integration with systems that predate the cloud. Actions wins on operational cost and on proximity — the automation lives with the code it automates, and it is reviewed in the same pull request.

Neither is a general improvement on the other. Migrating a working Jenkins installation to Actions for its own sake is a large project with a modest payoff.

A legitimate question: your CI is three commands, so why not a script?

You should still have the script. The distinction is that Actions provides what a script does not: a trigger (something must run it), a runner (something must own the machine), credentials (scoped, short-lived, not on someone’s laptop), reporting (a status check on the pull request), and history.

The good arrangement keeps the logic in the script and uses the workflow to invoke it:

- run: ./scripts/test.sh

That is testable locally, portable if you leave GitHub, and reviewable by people who do not know workflow YAML. A workflow that inlines forty lines of shell is harder to test and harder to move.

Both automate GitHub, and they suit different shapes of problem.

GitHub ActionsGitHub App
TriggerRepository events, schedulesWebhooks it subscribes to
Runs onGitHub’s runners, or yoursYour infrastructure
ScopeIts own repositoryEvery repository it is installed on
IdentityThe workflow’s tokenThe App
CostRunner minutesYour hosting
Best forAnything touching the repository’s codeCross-repository, multi-tenant integrations

The dividing line is scope. A workflow acts on its own repository — the built-in token cannot reach another. Anything spanning repositories needs an App, or a workflow that mints an App token.

Actions has quotas, and knowing they exist prevents surprises.

Minutes. Free for public repositories on standard runners. For private repositories, from an included allowance, then billed — with Windows and macOS costing multiples of Linux.

Storage. Artifacts and caches consume storage against an allowance, with retention policies.

Concurrency. How many jobs run simultaneously depends on plan and runner type.

Duration. Individual jobs and whole runs have maximum durations.

Forgetting actions/checkout. The runner starts empty; your code is not there.

Assuming jobs share a filesystem. Each job gets its own runner. Passing files between them needs artifacts; passing values needs outputs.

Assuming jobs run in order. They are parallel unless needs: says otherwise.

Leaving permissions at the default. Declare them; one line.

Treating Actions as only CI. Scheduled and event-driven automation is often where it pays most.

Self-hosting for cost. The operational and security burden is the real price.

  1. Create a disposable public repository.
  2. Add .github/workflows/hello.yml containing the minimal example above.
  3. Commit and push to main, then run gh run list and gh run view --log.
  4. Delete the actions/checkout step and push again. Observe that ls still works — the runner exists, your files do not.
  5. Add a second job with no needs: and confirm both start at once.
  6. Add needs: to the second job and confirm it now waits.

Steps 4 and 5 demonstrate the two facts that cause the most early confusion, and both are much more convincing observed than read.

Terms from other CI systems, mapped:

ElsewhereGitHub Actions
Jenkins pipeline / GitLab pipelineWorkflow
Jenkins stage / GitLab stageJob (parallel by default; use needs for order)
Jenkins agent / GitLab runnerRunner
Jenkins step / GitLab script lineStep
Jenkins shared library / GitLab includeReusable workflow or composite action
Jenkins pluginAction
GitLab rules:on: filters plus if:
CircleCI orbAction, or reusable workflow

The mapping that misleads most is stage → job. In most systems stages are sequential by definition; in Actions, jobs are parallel by default and ordering is opt-in through needs. A migrated pipeline that behaves unexpectedly is very often this — everything running at once because nothing declared a dependency.

  • GitHub Actions runs code in response to repository events; CI is one use among several.
  • The layers are event, workflow, run, job, runner, step, and command-or-action.
  • The platform and a reusable action share a name and are different things.
  • Runners are ephemeral and start empty — actions/checkout is what puts your code there.
  • Jobs are isolated and parallel by default.
  • Self-hosting is for capability, not cost.
  • Actions acts on its own repository; cross-repository automation needs an App.

Every run has a structure the interface renders and the CLI exposes, and knowing the vocabulary makes debugging much faster.

Terminal window
gh run list --limit 10
gh run view RUN_ID
gh run view RUN_ID --log-failed
gh run watch RUN_ID --exit-status

A run has a conclusion: success, failure, cancelled, skipped, timed_out, action_required or neutral. Those are distinct states and conflating them causes confusion — a skipped job did not fail, and a run waiting on a deployment approval is action_required rather than stuck.

Within a run, each job has its own conclusion, and within a job each step does. A failed run means some job failed; a failed job usually means one step did.

Terminal window
gh run view RUN_ID --json jobs \
--jq '.jobs[] | {name, conclusion, steps: [.steps[] | select(.conclusion != "success") | .name]}'

That prints only the steps that did not succeed, per job, which is the fastest way to locate a failure in a run with many jobs.

Worth stating plainly, because the boundaries are not obvious.

It can read and write its own repository (subject to permissions), call the GitHub API as itself, reach the internet, install software on the runner, and store artifacts and caches.

It cannot reach another repository with the built-in token, persist anything on the runner between jobs, exceed its plan’s concurrency, or run longer than the platform’s maximum job duration.

It should not be given credentials it does not need, execute untrusted code with secrets in reach, or be the only copy of anything.

That last point is worth expanding. A workflow is not a backup: artifacts expire, caches are evicted, and logs are retained for a limited period. Anything you need to keep belongs in a Release or an artefact registry.

An honest section, because “use Actions for everything” is a common failure.

When the work belongs on a schedule you control precisely. Scheduled workflows are queued and can be delayed; they are unsuitable for anything time-critical.

When it needs to run continuously. Actions runs jobs, not services. A long-running listener is a service, not a workflow.

When the compute is the point. Long-running builds on private repositories consume billed minutes quickly, and a dedicated build system may be cheaper at scale.

When it must not depend on GitHub’s availability. A deployment pipeline that cannot run during a GitHub incident is a real operational consideration.

When the logic belongs in the application. Business logic in a workflow is logic that cannot be tested locally and is invisible to anyone reading the codebase.

The three previous pillars built a repository where changes arrive as pull requests and merge under policy. Actions is what makes that policy meaningful.

A required review says a human looked. A required check says the code builds, the tests pass and the scanner found nothing — and that check is a workflow. Without Actions, repository governance can only enforce process; with it, governance can enforce correctness.

That is the sense in which this pillar completes the sequence: Pillar 3 gave you the gate, and this one gives you something worth putting behind it.

GitHub Actions runs code in response to repository events. Workflows contain jobs, jobs run on runners, and steps are either shell commands or reusable actions.

The two facts that cause the most early confusion are that runners start empty — nothing is checked out until actions/checkout does it — and that jobs share nothing, including the filesystem, even when one depends on another.

Everything else in this pillar is that model applied with increasing care.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A workflow's first step runs `npm test` and fails with 'package.json not found'. What is missing?
Show answer

`actions/checkout` — the runner starts empty; nothing has put the repository there — Runners are ephemeral and start with no copy of your code. `actions/checkout` is the step that clones the repository into the workspace.

Job A writes a file; job B, with no `needs:`, reads it. What happens?
Show answer

B does not see the file — jobs run on separate runners, and in parallel unless `needs:` is set — Each job gets its own runner and filesystem, and jobs are parallel by default. Passing files between jobs needs artifacts; passing values needs outputs; ordering needs `needs:`.

What does a `permissions:` block with a single scope declared do to the other scopes?
Show answer

Sets every undeclared scope to `none` — Declaring any permission resets all others to none. That is why one line makes a workflow least-privilege — and why an incomplete block can break a step that needed a scope you forgot.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.