GitHub Actions Fundamentals#

GitHub Actions is CI/CD built into GitHub. Workflows are YAML files in .github/workflows/. They run on GitHub-hosted or self-hosted machines in response to repository events. No external CI server required.

Workflow File Structure#

Every workflow has three levels: workflow (triggers and config), jobs (parallel units of work), and steps (sequential commands within a job).

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
      - run: go test ./...

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: golangci/golangci-lint-action@v6

Jobs run in parallel by default. Steps within a job run sequentially. Each job gets a fresh runner – no state carries over between jobs unless you explicitly pass it via artifacts or outputs.

Event Triggers#

The on key defines what triggers the workflow.

on:
  push:
    branches: [main, 'release/**']
    paths: ['src/**', 'go.mod']
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        type: choice
        options: [staging, production]
  repository_dispatch:
    types: [deploy-trigger]
  • push/pull_request: the most common triggers. Use paths to skip workflows when irrelevant files change.
  • schedule: cron-based triggers. Runs on the default branch only.
  • workflow_dispatch: manual trigger from the GitHub UI or API, with optional input parameters.
  • repository_dispatch: webhook-triggered. Call POST /repos/{owner}/{repo}/dispatches with an event type to trigger it from external systems.

Runners#

Runners are the machines that execute jobs.

jobs:
  build:
    runs-on: ubuntu-latest      # GitHub-hosted Linux x86
  build-arm:
    runs-on: ubuntu-24.04-arm   # GitHub-hosted Linux ARM64
  build-mac:
    runs-on: macos-latest        # GitHub-hosted macOS
  build-custom:
    runs-on: [self-hosted, linux, gpu]  # Self-hosted with labels

GitHub-hosted runners are ephemeral – a fresh VM for every job. Self-hosted runners persist, which means faster startup (no VM provisioning) but you must manage cleanup and security.

Step Types: uses vs run#

Steps are either action references (uses) or shell commands (run):

steps:
  # Action from the marketplace
  - uses: actions/checkout@v4

  # Action with inputs
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'

  # Shell command
  - run: npm test

  # Multi-line shell command
  - name: Build and verify
    run: |
      npm run build
      ls -la dist/

Pin actions to a full commit SHA for security in production workflows:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Tag references (@v4) can be moved by the action author. SHA pinning prevents supply chain attacks.

Passing Data Between Steps#

Steps in the same job share a filesystem but need explicit mechanisms for structured data.

steps:
  - name: Get version
    id: version
    run: |
      VERSION=$(cat VERSION)
      echo "version=$VERSION" >> "$GITHUB_OUTPUT"

  - name: Use version
    run: echo "Building version ${{ steps.version.outputs.version }}"

Write to $GITHUB_OUTPUT to set step outputs. Reference them with steps.<id>.outputs.<name>.

For environment variables shared across steps:

steps:
  - name: Set env
    run: echo "DEPLOY_ENV=staging" >> "$GITHUB_ENV"

  - name: Use env
    run: echo "Deploying to $DEPLOY_ENV"

To pass data between jobs, use job outputs:

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.ver.outputs.version }}
    steps:
      - id: ver
        run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"

  deploy:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.prepare.outputs.version }}"

Artifacts#

Upload files from one job, download in another:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build
      - uses: actions/upload-artifact@v4
        with:
          name: binary
          path: dist/myapp
          retention-days: 5

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: binary
      - run: ./myapp --version

Artifacts are the only way to pass files between jobs. They are stored by GitHub and count toward your storage quota. Set retention-days to avoid accumulating old artifacts.

Caching#

Cache dependencies to speed up workflows:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      ${{ runner.os }}-go-

The key must change when dependencies change. restore-keys provide fallback – a partial match restores a stale cache, which is still faster than downloading everything from scratch.

Many setup actions have built-in caching:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'       # Automatically caches node_modules

Secrets and Environment Variables#

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production    # Uses environment-scoped secrets
    steps:
      - run: deploy --token "${{ secrets.DEPLOY_TOKEN }}"
        env:
          AWS_REGION: us-east-1

Secrets are configured at the repository or environment level in GitHub settings. They are masked in logs. The built-in GITHUB_TOKEN provides scoped access to the repository’s own API – no PAT required for basic operations.

Set GITHUB_TOKEN permissions explicitly:

permissions:
  contents: read
  packages: write
  pull-requests: write

Concurrency Control#

Prevent duplicate runs and manage deployment queues:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

This cancels any in-progress run for the same branch when a new push arrives. For deployments where you do not want cancellation:

concurrency:
  group: deploy-production
  cancel-in-progress: false   # Queue instead of cancel

Reusable Workflows#

Define a workflow that other workflows can call:

# .github/workflows/reusable-build.yml
on:
  workflow_call:
    inputs:
      go-version:
        required: true
        type: string
    secrets:
      deploy-token:
        required: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ inputs.go-version }}
      - run: go build ./...

Call it from another workflow:

jobs:
  call-build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      go-version: '1.23'
    secrets:
      deploy-token: ${{ secrets.DEPLOY_TOKEN }}

Reusable workflows reduce duplication across repositories. They can live in the same repo or a central .github repository.