GitHub Actions Demystified: Turns Out It’s Not Witchcraft
1. What is CI/CD in DevOps ?
CI/CD (Continuous Integration and Continuous Deployment) is a development practice that automates the process of testing, building, and deploying code. With CI, every code change is automatically tested to ensure nothing breaks. CD then takes it further by deploying those verified changes to production without manual effort. Together, they help developers release updates faster, more reliably, and with fewer bugs.
CI/CD = Code → Test → Build → Deploy → Repeat (automatically)
2. Why GitHub Actions ?
- Your code is already maintained on github and pipelines can live alongside your code; creating a workflow is as simple as committing a YAML file under
.github/workflows/. - Market place of thousands of community-maintained actions help you automate everything. You can also host your own actions.
- A large user base encourages tooling vendors to deliver faster runners, deeper observability, and polished developer experiences.
- Workflows and actions are written in YAML that is very easy to adapt and learn. For complex workflows you can switch to other platforms but only for really complex workflows.
- Github have good monitoring and debugging survice provided in build, only for advance analytics you choose other platforms.
I like CircleCI only for its one feature Build and Retry with SSH
3. Anatomy of a GitHub Workflow
3.1 Workflow
A Workflow in Github is a collection of correlated Github Actions defined in a single file. The Workflow will have a trigger e.g.: on: push and define a list of Jobs. Each job can be composed or one or multiple Steps, and each step is generally speaking a Github Action.
The term Github Action, however, is more frequently used refer to the reusable, predefined actions you can get from the marketplace.
An action is a song. A workflow is a playlist.
Simple workflow example
# This is a Workflow in YAML
name: Hello World # Name of the workflow
on:
workflow_dispatch: # Event trigger
jobs: # job/Action
say-hello-inline-bash:
runs-on: ubuntu-24.04 # Runner
steps: # steps
- run: echo "Hello from an inline bash script in a GitHub Action Workflow!"
3.2 Events
An event is a specific activity in a repository that triggers a workflow run. For example, an activity can originate from GitHub when someone creates a pull request, opens an issue, or pushes a commit to a repository.
Types of Events
| Event | Description |
|---|---|
push |
Triggered whenever code is pushed to a branch. |
pull_request |
Runs when a pull request is opened, updated, or merged. |
workflow_dispatch |
Allows you to manually trigger a workflow from the GitHub UI. |
schedule |
Runs workflows on a set schedule using cron syntax. |
issues |
Triggers when an issue is opened, closed, or labeled. |
release |
Runs when a new release is published in the repository. |
fork |
Fires when someone forks your repository. |
watch |
Runs when someone stars (watches) your repo. |
delete |
Triggered when a branch or tag is deleted. |
pull_request_target |
Similar to pull_request, but runs in the base repo context. |
Workflow Triggers Example (Push, Pull Request, Schedule, Manual)
on:
push:
branches:
- "example-branch/*" # Trigger on push to branches matching this pattern
pull_request:
paths:
- "03-core-features/filters/*.md" # Trigger only if Markdown files change
- "!03-core-features/filters/*.txt" # Ignore TXT file changes
schedule:
- cron: "0 0 * * *" # Trigger daily at midnight UTC
workflow_dispatch: # Allow manual trigger from GitHub UI
3.3 Jobs
A job is like a stage in your pipeline — a self-contained set of steps that GitHub Actions executes to move your code from build → test → deploy. Jobs run in parallel by default, but they can be configured to run sequentially if there are dependencies between them. Each job runs in its own fresh instance of a virtual environment, allowing you to run jobs on different operating systems or environments within the same workflow.
Types of Jobs
- Independent Jobs: Run parallelly without depending on other jobs.
- Dependent Jobs: Run only after one or more specified jobs complete using
needs. - Matrix Jobs: Run multiple variations of a job in parallel across different parameters (like OS or Node versions).
- Reusable / Composite Jobs: Predefined jobs that can be reused across workflows to reduce duplication.
3.4 Steps
Steps are the smallest unit of work within a job and can either run a script or an action. A step to run a script could execute shell commands, while a step that uses an action could perform more complex tasks like setting up a Node environment or deploying to AWS.
Types of Steps
- run: Executes a command or script directly in the workflow runner, useful for building, testing, or executing script.
- run with shell : Similar to run, but lets you specify the shell to execute the command. Useful when you need a specific shell like Bash, PowerShell, or Python.
- uses with with : Uses a pre-built action and passes parameters via with. Useful for setting up environments or performing common tasks without writing commands manually.
- Conditional (if): Executes a step only if a condition is met. Useful for running deploys, notifications, or special tasks depending on branch, status, or environment.
Example event driven workflow
# Sample GitHub Actions workflow
name: Simple CI
on:
push:
branches:
- main # Trigger on push to main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4 # Pull repo code
- name: Install dependencies
run: npm install # Install project packages
- name: Run build
run: npm run build # Build project
test:
runs-on: ubuntu-latest
needs: build # Run after build completes
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run tests
run: npm test # Execute tests
3.5 Runners
Every job must declare where it runs. The runs-on key determines the operating system, architecture, CPU, and memory available to your job, and you can layer on containers or custom labels to shape the environment further.
Types of Runners
- GitHub-hosted runners: Ready-to-use Ubuntu, Windows, or macOS machines for quick setup and free open-source usage.
- Third-party runners: Hosted by companies offering faster builds, lower cost, and extra features like caching and monitoring.
- Self-hosted runners: You manage your own machines, allowing private-network execution but requiring infrastructure management.
Example Workflow with 3 Types of Runners
name: Multi-Runner Workflow
on:
push:
branches:
- main
jobs:
# 1. GitHub-hosted runner
build:
runs-on: ubuntu-latest # GitHub-hosted Ubuntu runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build project
run: echo "Building on GitHub-hosted runner"
# 2. Third-party hosted runner
lint:
runs-on: [self-hosted, linux, third-party] # Example tag for third-party runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Lint code
run: echo "Linting on third-party hosted runner"
# 3. Self-hosted runner
deploy:
runs-on: [self-hosted, linux, my-private-runner] # Your own runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy
run: echo "Deploying on self-hosted runner"
4 Environment Variable and Scope
In GitHub Actions, environment variables are values that can be reused throughout your workflow. They have different scopes: workflow-wide, job-specific, and step-specific. Workflow-wide variables are accessible by all jobs and steps, job-specific variables are available only within that job, and step-specific variables override both workflow and job variables within that step. For example, you might define WORKFLOW_VAR at the top of your workflow for all jobs, JOB_VAR inside a specific job, and STEP_VAR inside a step — the step can access all three, while other jobs cannot see STEP_VAR.
name: Env Variable Example
on: push
env: # Workflow-wide environment variable
WORKFLOW_VAR: "Hello"
jobs:
example-job:
runs-on: ubuntu-latest
env: # Job-specific environment variable
JOB_VAR: "World"
steps:
- name: Step 1
run: echo "$WORKFLOW_VAR $JOB_VAR" # Access both workflow , job variables
env: # Step-specific environment variable
STEP_VAR: "!"
- name: Step 2
run: echo "$WORKFLOW_VAR $JOB_VAR $STEP_VAR" # Step can access all scopes
5 Passing Data between Jobs
In GitHub Actions, passing data between jobs or workflows requires artifacts, outputs, or workflow calls, because each job runs in its own isolated environment by default.
- Use $GITHUB_ENV to create job-scoped environment variables — available only within the same job.
- Use $GITHUB_OUTPUT in a step with an id to create job outputs — these can be accessed by downstream jobs via needs.
jobs:
producer:
runs-on: ubuntu-latest
outputs:
foo: ${{ steps.gen.outputs.foo }}
steps:
- name: Generate value
id: gen
run: |
echo "foo=bar" >> "$GITHUB_OUTPUT"
echo "FOO=bar" >> "$GITHUB_ENV"
consumer:
runs-on: ubuntu-latest
needs: producer
steps:
- name: Access producer output
run: echo "Foo from producer: ${{ needs.producer.outputs.foo }}"
# FOO from $GITHUB_ENV is not available here
6. Caching in GitHub Actions
Caching lets you save and reuse files or dependencies between workflow runs to speed up your CI/CD pipelines. Unlike artifacts, which are for long-term storage, caches are meant for intermediate data like node_modules or build outputs. You control caches using a key, and GitHub restores them in future runs if the key matches. For example, using actions/cache, you can store a directory and avoid reinstalling dependencies on every run, dramatically reducing build time.
Key Points
- Commonly cached items: package manager dependencies (npm, pip, Maven), compiled binaries, or build outputs.
- Uses a key to identify the cache — GitHub restores the cache if the key matches or partially matches.
- Cache is job-specific but can be shared across runs of the same workflow.
jobs:
cache-demo:
runs-on: ubuntu-latest
steps:
# 1️⃣ Checkout code
- name: Checkout repo
uses: actions/checkout@v4
# 2️⃣ Restore cache
- name: Restore cache
id: cache-step
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# 3️⃣ Install dependencies if cache missed
- name: Install dependencies
if: steps.cache-step.outputs.cache-hit != 'true'
run: npm install
# 4️⃣ Update cache (GitHub automatically saves cache when key is new)
# You don’t need a separate step; cache is keyed by package-lock.json hash.
# If the lockfile changes, the hash changes → new cache is created.
# 5️⃣ Run build
- name: Build project
run: npm run build
7. GitHub Actions Permissions and Authentication
Permissions
You can use the GITHUB_TOKEN by using the standard syntax for referencing secrets: ${{ secrets.GITHUB_TOKEN }}. Examples of using the GITHUB_TOKEN include passing the token as an input to an action, or using it to make an authenticated GitHub API request.
Types of Permissions
- Default GITHUB_TOKEN permissions : Each workflow run gets a GITHUB_TOKEN to authenticate API requests. By default, it has read/write access to contents, issues, pull requests, and actions.
- Fine-grained permissions : You can explicitly set permissions per workflow to limit access.
- Job-level overrides : You can also override permissions for specific jobs if needed.
name: Permissions Demo
# 🌍 1️⃣ Workflow-level permissions (default for all jobs)
permissions:
contents: read # Can only read repo files
issues: write # Can create/edit issues
actions: none # Cannot modify other workflows
jobs:
build:
runs-on: ubuntu-latest
# ⚙️ 2️⃣ Job-level override (stricter than workflow-level)
permissions:
contents: read # Only read access to repo
issues: none # Disable issue access for this job
steps:
- name: Checkout code
uses: actions/checkout@v4
# 🔐 3️⃣ Step-level control (use a different token)
- name: Create issue using PAT
env:
# Custom token with different scope
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
run: |
gh issue create --title "Build Completed" --body "The build job has finished successfully."
Authentication
When your GitHub workflow interacts with external services (like AWS, Google Cloud, or Docker Hub), it needs a way to prove its identity — that’s called authentication.
- Static Credentials (Secrets) You create long-lived credentials like API keys, personal access tokens, or cloud access keys. You store them in GitHub Secrets — securely encrypted, not visible in logs.
- OIDC Federation (OpenID Connect) Instead of storing secrets, GitHub issues a short-lived identity token every time a workflow runs. The cloud provider (e.g., AWS, GCP, Azure) verifies this token and gives the workflow temporary credentials — valid only for a few minutes.
8. Secrets and variables
Secrets and Variables help you manage configuration and sensitive data cleanly. Secrets are used for confidential information such as API keys, tokens, or passwords — they stay hidden in logs and are securely encrypted. Variables, on the other hand, store non-sensitive values like environment names, build versions, or feature flags, making workflows easier to maintain and reuse. Together, they keep your pipelines both secure and organized.
name: Secrets and Variables Example
on: push
jobs:
demo:
runs-on: ubuntu-latest
env:
APP_ENV: ${{ vars.ENVIRONMENT }}
API_KEY: ${{ secrets.API_KEY }}
steps:
- name: Show environment details
run: |
echo "Running in environment: $APP_ENV"
echo "Using API Key: $API_KEY"
9. Reusable automation units
Creating your own reusable automation units that can be shared across multiple workflows or repositories. Instead of writing the same steps repeatedly, you can package them as a custom action — making your CI/CD process cleaner and more modular.
9.1 Composite Actions
Used for bundling multiple shell-based steps into one reusable action. They’re great for lightweight logic that doesn’t need Node.js or Docker. Easy to share across repositories to keep workflows DRY (Don’t Repeat Yourself).
Custom Composite
# .github/actions/hello/action.yml
name: "Say Hello"
description: "Prints hello messages"
runs:
using: "composite"
steps:
- run: echo "Hello, ${{ inputs.name }}!"
Our workflow
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/hello
with:
name: Ashish
9.2 Reusable Workflows
Encapsulate an entire workflow that can be triggered from other workflows. Perfect for standardizing pipelines like build, test, or deploy across repos. They simplify maintenance by updating one shared workflow instead of many.
# .github/workflows/deploy.yml
on: workflow_call
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "Deploying app..."
# Caller workflow
jobs:
call-deploy:
uses: org/repo/.github/workflows/deploy.yml@main
9.3 JavaScript / TypeScript Actions
Used for writing actions with custom logic and GitHub API integration. Run directly in the GitHub runner using Node.js. Ideal for complex automations like commenting on PRs or analyzing code.
# workflow.yml
steps:
- uses: actions/checkout@v4
- uses: my-org/custom-js-action@v1
9.4 Container Actions
Run inside a Docker container, letting you use any language or dependency. Perfect for heavy environments like Python, Go, or ML toolchains. They provide complete isolation and reproducibility across runners.
# action.yml
name: "Python Builder"
runs:
using: "docker"
image: "Dockerfile"
# Dockerfile
FROM python:3.12
RUN pip install -r requirements.txt
ENTRYPOINT ["python", "build.py"]
Here’s a cooler, blog-friendly version of your headings:
10 Turbocharge, Simplify & Secure Your Pipelines
10.1 Speed Hacks: Supercharge Your Workflows
- Measure first: Track timing to pinpoint bottlenecks.
- Fail fast & queue smart: Run likely-to-fail tests early and reduce wait times.
- Do less work: Use path filters, job conditions, step guards, and caching.
- Max out resources: Parallel jobs, multi-core builds, avoid unnecessary emulation.
- Track performance: Monitor metrics to prevent slowdowns.
10.2 Cache Like a Pro: Save Time & Bandwidth
- Git checkout cache: Fetch only deltas, not the full repo.
- Toolchains: Cache runtimes (Node, Go, Java, etc.).
- Dependencies: Cache package manager folders (
npm,pip,cargo). - Build/test artifacts: Persist compiled outputs and test fixtures.
- Container layers: Keep base images hot and reuse unchanged layers.
10.3 Workflow Zen: Keep It Clean & Maintainable
- Define a consistent CI API: Standardize commands (
install,test,build,dev). - Centralize reusable logic: Composite actions & reusable workflows.
- Optimize local feedback loops: Run CI commands locally to reduce CI noise.
10.4 Security Smarts: Lock It Down
- Minimum permissions per workflow/job.
- Short-lived tokens > long-lived credentials.
- Maintain allow list of trusted actions.
- Pin actions by SHA for repeatable builds.
- Don’t run forked PRs on self-hosted runners.
- Use GitHub environments for approvals on sensitive deployments.
Conclusion
GitHub Actions isn’t just a CI/CD tool — it’s an automation powerhouse built right into your repo. From streamlining builds and testing to securely deploying across environments, Actions help teams ship faster with confidence. By mastering caching, reusability, and security best practices, you can turn your workflows into efficient, maintainable, and secure pipelines. Whether you’re automating simple tasks or orchestrating complex systems, GitHub Actions gives you the flexibility and control to focus on what matters most — building great software.