What Is GitHub Actions?
GitHub Actions is a CI/CD and workflow automation platform built directly into GitHub. It lets you define automated workflows β sequences of tasks triggered by events in your repository β using YAML files stored in your codebase at .github/workflows/.
Before GitHub Actions (launched in 2019), most teams used external CI services like Travis CI, CircleCI, or Jenkins. GitHub Actions changed that by putting automation inside GitHub itself: no separate account, no webhook configuration, no external service to maintain.
Free tier: Public repositories get unlimited GitHub Actions minutes. Private repositories get 2,000 free minutes per month on the free plan, with additional minutes billed per-minute.
GitHub Actions is used for: running tests on every pull request, deploying to production on merge, publishing packages to npm or PyPI, scanning for security vulnerabilities, sending notifications, and virtually any other automated task you can script.
Key Concepts: Workflows, Jobs, Steps, Runners
Understanding GitHub Actions requires understanding four core concepts and how they nest inside each other:
- Workflow: A YAML file in
.github/workflows/. One repository can have multiple workflows. Each workflow defines when it runs (triggers) and what it does (jobs). - Job: A set of steps that run on the same runner. Jobs within a workflow run in parallel by default. You can configure dependencies to run them sequentially.
- Step: A single task within a job β either a shell command (
run:) or an Action (uses:). Steps within a job run sequentially and share the runner's filesystem. - Runner: A virtual machine that executes your jobs. GitHub provides Ubuntu, Windows, and macOS runners. You can also host your own self-hosted runners.
- Action: A reusable unit of workflow logic. Actions are published to the GitHub Marketplace or referenced directly from a repository.
actions/checkout@v4is the most commonly used β it clones your repository onto the runner.
Your First Workflow
A minimal workflow that runs tests on every push looks like this:
name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci - name: Run tests run: npm test
Save this file, push to GitHub, and you'll see the workflow appear under the Actions tab in your repository. GitHub automatically detects any .yml or .yaml file in .github/workflows/ and registers it as a workflow.
Triggers: When Workflows Run
The on: key defines what triggers a workflow. GitHub supports over 30 event types. The most commonly used:
pushβ runs when commits are pushed to matching branches or tagspull_requestβ runs when a PR is opened, updated, or synchronisedscheduleβ runs on a cron schedule (e.g. nightly security scans)workflow_dispatchβ manual trigger with optional inputsreleaseβ runs when a GitHub release is publishedworkflow_callβ allows a workflow to be called as a reusable sub-workflow
Schedule example: cron: '0 2 * * *' runs at 2am UTC every day. GitHub Actions uses standard cron syntax. Scheduled workflows only run on the default branch.
You can combine multiple triggers. A common pattern is to run fast tests on every PR but full integration tests only on merges to main using separate workflow files or job conditions.
Jobs, Steps, and Actions
Jobs run in parallel by default. To make Job B wait for Job A to succeed, use needs::
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test build: needs: test # only runs if test succeeds runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build deploy: needs: build # only runs if build succeeds runs-on: ubuntu-latest environment: production steps: - name: Deploy to production run: ./deploy.sh
Each step in a job runs sequentially. If any step fails, subsequent steps are skipped and the job is marked as failed β unless you use continue-on-error: true or if: always() conditions.
Secrets and Environment Variables
Never hardcode credentials in workflow files. GitHub provides encrypted secrets stored at the repository, environment, or organisation level. Access them with ${{ secrets.SECRET_NAME }}.
Secret scanning: GitHub automatically scans pushed commits for known credential patterns. But this catches secrets after the fact β use pre-commit hooks and AquilaX scanning to catch them before they're pushed.
For non-sensitive configuration, use environment variables defined at the workflow, job, or step level with the env: key. Secrets can also be passed as environment variables to avoid them appearing in command arguments (which would show in logs):
steps: - name: Deploy env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} run: ./deploy.sh # accesses $API_KEY from env, not args
Matrix Builds for Multiple Environments
Matrix builds let you run the same job across multiple configurations β Node versions, operating systems, Python versions β without duplicating your workflow. GitHub Actions fans out matrix combinations automatically:
jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] node: ['18', '20', '22'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci && npm test
This creates 9 jobs (3 OS Γ 3 Node versions) running in parallel. Use fail-fast: false to let all matrix jobs complete even if one fails, which is useful for identifying which combinations have issues.
Caching Dependencies for Speed
Every runner starts fresh. Without caching, npm install or pip install runs from scratch on every workflow run. The actions/cache action stores and restores directories between runs:
steps: - uses: actions/checkout@v4 - name: Cache node_modules uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - run: npm ci
The cache key includes a hash of your lock file β so any dependency change invalidates the cache. Most language-specific setup actions (actions/setup-node, actions/setup-python) now have built-in caching via a cache: input, which is simpler than using actions/cache directly.
Real-World CI/CD Pipeline Example
Here's a complete pipeline that tests, builds, scans for vulnerabilities, and deploys a Node.js application:
name: Full CI/CD Pipeline on: push: branches: [main] pull_request: permissions: contents: read # least-privilege: only what's needed jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci && npm test security-scan: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run AquilaX security scan uses: aquilax/scan-action@v1 with: api-key: ${{ secrets.AQUILAX_API_KEY }} fail-on: critical deploy: needs: [test, security-scan] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - name: Deploy run: ./scripts/deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
GitHub Actions best practices recap: Always pin actions to a specific SHA or tag. Use permissions: to restrict token scope. Store all credentials as secrets. Use environments for production deployments to require manual approval.
Secure Your GitHub Actions Pipelines
AquilaX scans your workflows for hardcoded secrets, expression injection risks, overpermissive tokens, and unpinned actions β automatically, on every push.
Start Free Scan