Monday, 12 August 2024

Introduction to GitHub Actions





GitHub Actions (GHA): 
  • Workflow Automation Service offered by the GitHub
  • Allows you to automate all kinds of repository-related processes and actions
  • Service that offers various automations around the code that is stored on GitHub, around these repositories that hold that code.
  • Free for public repositories
Two main areas of processes that GitHub Actions can automate:
  • CI/CD processes (Continuous Integration/Continuous Delivery/Continuous Deployment) - methods for automating app development, testing, building and deployment
    • Continuous Integration is all about automatic handling code changes - integrating new code or code changes into an existing code base by building that code automatically. So that changed code by testing it automatically and by then merging it into existing code.
    • Continuous Delivery or deployment is about publishing new versions of your app or package or website automatically after the code has been tested and integrated
    • Example: After we make a change to the website code, we want to automatically upload and publish a new version of our website
    • GitHub Actions helps setting up, configuring and running such CI/CD workflows
    • It makes it very easy for us to set up processes that do automatically build, test, and publish new versions of our app, website, or package whenever we make a code change.
  • Code and repository management - automating:
    • code reviews
    • issue management

Key Elements


  • Workflows
  • Jobs
  • Steps


Workflows:
  • Attached to GitHub repositories
  • We can add as many workflows to GitHub repository as we wish
  • The first thing we build/create when setting up an automation process with GHA
  • Include one or more jobs
  • Built to set up some automated process that should be executed
  • Not executed all the time but on assigned triggers or events which define when a given workflow will be executed. Here are some examples of events that can be added:
    • an event that requires manual activation of a workflow
    • an event that executes a workflow whenever a new commit is pushed to a certain branch
  • Defined in a YAML file at this path: <repo_root>/.github/workflows/<workflow_name>.y[a]ml

Workflow can have the following elements:
  • name - name of the workflow
  • on - defines a workflow trigger. It can have some of these values:
    • workflow_call - can have the following attributes:
      • inputs - an object containing one or more key-value pairs. inputs are only supported for workflow_dispatch (manual trigger). In each such pair a key is the names of the input variable which is used for referencing this input later in yaml document as ${{ inputs.<INPUT_VAR_NAME> }}. A value is an object containing one or more key-value pairs where keys can be:
        • required: boolean
        • type: string |
        • default - string, number or boolean - a default value of the input variable, if one is not specified/set 
    • pull_request
      • Runs the workflow when activity on a pull request in the workflow's repository occurs (by default when a pull request is opened or reopened or when the head branch of the pull request is updated)
      • We can use the branches or branches-ignore filter to configure our workflow to only run on pull requests that target specific branches. E.g. if we specify master under branches, that means that this workflow will be triggered on any new/updated Pull Request which has master as a target (base) branch. Name of the feature branch can be arbitrary and is not important.
      • Inputs are NOT allowed for workflows triggered by pull_request. The pull_request event does not accept user-defined inputs because it is triggered automatically when a PR is opened, synchronized, or updated.
      • If you need dynamic behavior in a pull_request workflow, you can:
        • Use environment variables (env)
        • Use a GitHub secret or variable (secrets / vars)
    • push - run the workflow when a push is made to any branch in the workflow's repository
    • [ array of values above ] - if we want to combine multiple different triggers
  • permissions - workflow permissions
  • jobs - list of jobs. Each job is defined by stating its name followed by semicolon (e.g. jobA:). For each job we can define:
    • name: - Descriptive name of the step
    • runs-on: e.g. ubuntu-latest
    • steps: List of steps. It's a YAML list so each step object needs to be prepended with "- ". For each step we can define:
      • name: - step name 
      • uses: - name of the GitHub action e.g. actions/checkout@master
        • Best practice is not to use the latest tag (@master) but a fixed version, e.g. actions/checkout@v4
      • with: - block used to pass input parameters to an action
        • persist-credentials: boolean <-- specific input for certain actions, such as actions/checkout
      • run: - if we want to specify raw commands
      • env:
  • env - define environment variables available in all jobs
    • values can be hardcoded or read from GitHub secrets

name: ci

on: examples:

on:
  workflow_call:
    inputs:
      AWS_REGION:
        type: string
        default: eu-east-2

on:
  pull_request:
    types: [opened, reopened]


on:
  pull_request:
    types:
      - opened
    branches:
      - 'releases/**'
    paths:
      - '**.js'


permissions: examples:

permissions:
  id-token: write # aws-actions/configure-aws-credentials (OIDC)
  contents: read
  pull-requests: write # actions/github-script to create comment in PR

env:
  AWS_DEFAULT_REGION: us-east-2
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}




Jobs:
  • Contain one or more steps that will be executed in the order in which they're specified
  • Define a runner
    • Execution environment, the machine and operating system that will be used for executing these steps
    • Can either be predefined by GitHub (runners for Linux, Mac OS, and Windows) or self-hosted, custom, configured by ourselves
  • Steps will be executed in the specified runner environment/machine
  • If we have multiple jobs they run in parallel by default, but we can also configure them to run in sequential order, one job after another
  • We can also set up conditional jobs which will not always run, but which instead need a certain condition to be met.
    • if: This keyword allows you to specify a condition that must be true for the job or step to execute.

    if: startsWith(github.ref, 'refs/tags/')

This line is used in a GitHub Actions workflow to conditionally run a job or step only when the workflow is triggered by a tag event.

startsWith(github.ref, 'refs/tags/')
This expression uses the startsWith function to check if the github.ref context variable begins with the string 'refs/tags/'.

github.ref contains the full Git reference that triggered the workflow, such as refs/heads/main for a branch or refs/tags/v1.0.0 for a tag.

If the workflow was triggered by pushing a tag, github.ref will start with refs/tags/.



Each job can have the following attributes:
  • name - Job name which will be shown in GitHub Actions tab
  • needs
  • runs-on - runner type: ubuntu-latest or custom
  • env - its value is an object containing one or more key-value pairs where key is the environment variable name and value is its value. These environment values have the scope of the current job. 
  • steps; Steps are listed within steps key
  • outputs: 

If specified runner does not exist, GitHub workflow will hang indefinitely, with error like this:

Waiting for a runner to pick up this job...
Job is about to start running on the runner: non_existing_runner_name



Example:

# create job named "plan"
plan:
  needs: [conflict, format, validate, lint, security]
  name: plan
  runs-on: ${{ inputs.RUNNER }}
  defaults:
    run: 
      working-directory: ${{ inputs.TF_ROOT }}
  outputs:
    tfplan_identifier: ${{ steps.set_tfplan_output.outputs.tfplan_identifier }}
    tag_name: ${{ steps.get_tag.outputs.tag_name }}
  steps:
    - name: Step1
      run: echo "This is a Step 1" 
    - name: Print all available GitHub secrets
      run: | 
        echo "GitHub secrets:" 
        echo "$SECRETS_CONTEXT" | rev
      env:
        SECRETS_CONTEXT: ${{ toJson(secrets) }}
    - name: Get tag
        id: get_tag
       run: echo "tag_name=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
deploy:
    runs-on: ubuntu-latest
    needs: plan
    steps:
    - name: Print tag
      run: echo "Tag is ${{ needs.
plan.outputs.tag_name }}"


Steps:
  • Define the actual things that will be done
  • Example:
    • download the code in the first step
    • install the dependencies in the second step
    • run automated tests in the third step
  • Belong to jobs, and a job can have one or more steps
  • And a step is either:
    • a shell script
    • a command in the command line that should be executed (e.g. for simple tasks), or 
    • an action, which is another important building block
      • predefined scripts that performs a certain task
      • We can build our own actions or use third party actions
  • We must have at least have one step,
  • Steps are then executed in order, they don't run in parallel, but instead, step after step
  • Steps can also be conditional

Steps are elements of yaml list named steps

A step element can contain the following keys:
  • name - step name (step title)
  • id - a string uniquely identifying the step within enclosing job. Used in references to this step e.g. ${{ steps.<id>.outputs.<output_variable_name> }}
  • run - 
  • uses - name of the reusable GitHub Action 
  • with
  • env - its value is an object containing one or more key-value pairs where key is the environment variable name and value is its value. These environment values have the scope of the current step. To access its value, use ${process.env.<ENV_VAR_NAME>}.
    • environment variable can be used as workflow's internal variable 
    • some actions require certain environment variables to be defined and set e.g. https://github.com/terraform-linters/tflint/blob/master/docs/user-guide/plugins.md#avoiding-rate-limiting
  • continue-on-error - a boolean (true|false) value denoting whether execution of the job can resume even if this step errors out
  • run - an arbitrary bash script

Example step:

- name: Step 1
  uses: actions/github-script@v7
  env:
     PLAN_OUTPUT: "${{ steps.plan.outputs.stdout }}"
  with:
     script: |
        let plan = "${process.env. PLAN_OUTPUT}"

- name: TFLint Init
  run: tflint --init
  env:
     # https://github.com/terraform-linters/tflint/blob/master/docs/user-guide/plugins.md#avoiding-rate-limiting
     GITHUB_TOKEN: ${{ github.token }}



- name: Terraform Plan
  id: plan
  continue-on-error: true
  env:
    TF_ROOT: "${{ inputs.TF_ROOT }}" 
  run: |
    tfplan_identifier="${{ inputs.TF_APP_NAME }}-tfplan-expected"
    echo "tfplan_identifier=$tfplan_identifier" >> $GITHUB_OUTPUT
    terraform plan -var-file="prod.tfvars" -input=false -no-color -out=${tfplan_identifier}
    terraform-bin show -no-color $tfplan_identifier > "$tfplan_identifier.log"



How to create a Workflow?


Workflow can be created in two ways:
  • directly on the remote, via browser
  • in the local repo, and then pushed to remote
If we use browser, we need to go to our repo's web page and then click on Actions tab. There we can select a default Workflow or choose some other template. Default workflow creates the following file:

my-repo/.github/workflows/blank.yml:

# This is a basic workflow to help you get started with Actions

name: ci-prod-my-app

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v4

      # Runs a single command using the runners shell
      - name: Run a one-line script
        run: echo Hello, world!

      # Runs a set of commands using the runners shell
      - name: Run a multi-line script
        run: |
          echo Add other actions to build,
          echo test, and deploy your project.


How to trigger a workflow?



Workflows can be triggered on various events:
  • pushing changes on an arbitrary branch
  • creating a pull request (including a draft PR)
  • pushing a tag to remote
  • manually
  • ...
Certain triggers (like workflow_dispatch) requires the new workflow to be merged to main branch first before showing up in GitHub Actions tab.

CI workflows are usually triggered on pushing changes to remote or on creating a pull request.

Trigger on push to any branch:

on:
  push:
    branches:
      - '**'

Trigger on pull request on master branch:

on:
  pull_request:
    branches:
      - master

Trigger on pull request on master and prod branch:

on:
  pull_request:
    branches: [main, prod]


Trigger on pull request that include files at any of these paths:

on:
  pull_request:
    paths: ["path/to/my-app/prod/app/**", "terraform/modules/**", ".github/workflows/my-app-prod-tf-ci.yaml"]


CD workflows are usually triggered on pushing a tag to remote:

on:
  push:
    tags:
      - "my-app/prod/v*"


Workflows which un-deploy the apps or destroy resources are usually triggered manually:

on:
  workflow_dispatch:
   
workflow_dispatch-triggered workflow files need to be on the default branch (e.g. main). See Events that trigger workflows - GitHub Docs.

Once files are there, we can select against which branch we want to execute this workflow:



If we define input variables, they will be listed and we can enter their values:



Deployment Environments


CD (Deployment) workflows deploy applications and/or provision resources on the remote infrastructure. We want to be 100% sure that CD workflow won't deploy e.g. changes from test branch into production. To make sure there will be no mismatch in the commit hash and deployment environment, we can specify deployment environment in the workflow and also in GitHub specify protection rules for it e.g. which tag (defined by tag format) can deploy in that environment. 

Environments in a GitHub repository serve as configurable targets like production, staging, or development that help manage settings, secrets, and deployment strategies for different phases of a project’s lifecycle. They enable you to define specific contexts for deployments, enhance the security and control of your workflow operations, and regulate who can approve or trigger deployments to sensitive environments like production.​

Purpose of Environments


Segregation of Contexts: Environments allow you to separate deployment targets such as development, staging, and production. Each environment can have its own configuration, secrets, and rules.​

Security: Environment-specific secrets (like API keys) are only accessible within jobs that use that environment, safeguarding sensitive information from unauthorized access or accidental leaks.​

Controlled Deployments: You can enforce protections such as required reviewer approvals, deployment delays, or branch restrictions, which are especially useful for critical environments such as production.​

Audit and Oversight: GitHub tracks and displays deployment activity for each environment, providing audit trails and deployment histories.​

Using Environments in GitHub Workflows


Create Environments:

Go to your repository’s Settings and find the Environments section.

Add environments such as production, staging, or test, and configure options like required approvals, wait timers, or environment URLs.​



Configure Secrets and Variables:

Add secrets or variables specific to each environment; these override repository-level secrets of the same name when the workflow references the environment.​

Reference Environments in Workflows:

In your workflow YAML files, specify the environment with the environment keyword within jobs:


jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      ...

This ensures jobs have access to only those secrets and settings defined for the specified environment.​

Leverage Protections:

Set up approval rules, wait times, or limit which branches can trigger workflows for each environment to make deployment processes as robust or restricted as needed.​

Example Use Case

A deployment workflow can have separate jobs for development, staging, and production, each referencing their respective environment. This provides strict control over what is deployed, where, and under what circumstances, enabling safer and more reliable releases.​​

Environments are essential for preserving project integrity, securing secrets, and establishing clear, traceable deployment processes across the CI/CD pipeline in GitHub Actions

Useful Reusable Actions



Examples




GitHub Action Versions


Using @master as version is a bad practice. The risks:
  • No reproducibility — master is a moving target; the same workflow run can behave differently on different days
  • Silent breaking changes — any commit merged to the action's master immediately affects your workflow with no review or opt-in
  • Security risk — if the action's repo is compromised, a malicious commit to master runs in your pipeline instantly; pinning to a specific SHA or tag limits the blast radius

Best practice is to pin to an exact version tag (e.g. actions/checkout@v4) or even better a full commit SHA (e.g. actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683) — the SHA is tamper-proof even if a tag is force-pushed.

Two options for tagging all actions:
  • All use SHAs — most secure (immune to tag force-push), but opaque (de0fac2e... tells you nothing without a comment) and harder to maintain
  • All use version tags — human-readable, easy to update, but a malicious/accidental force-push to a tag would silently affect you

GitHub's own security hardening guide recommends full SHAs. A common middle ground is SHAs with a version comment, e.g.:

uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Note: @vM is called floating tag. It always points to the latest version which has M as a major version so over time it can point to different full versions - it floats.

Is it safe to use "@master" for our own custom actions which reside in our internal org's repository?


It's significantly safer than for third-party actions. Since your org controls that repo, the supply chain attack risk is essentially zero. The remaining risk is just accidental breakage from an unrelated commit landing on master mid-pipeline run.

That said, pinning to a SHA is still the more disciplined practice for reproducibility — you know exactly what ran in every pipeline. The downside is maintenance: whenever your-actions-repo/master gets meaningful updates (e.g. a new kubectl version), you have to come here and manually bump the SHA, whereas @master always picks it up automatically.

Both are defensible; the current pinned SHA approach just prioritises reproducibility over convenience.

GitHub Actions and Authentication with AWS


In GitHub Actions, named profiles don't exist. Environment variables (AWS_ACCESS_KEY_ID etc.) take precedence for credentials, but an explicit profile reference in the (aws) provider block can still cause the AWS SDK to error if the profile entry is missing from ~/.aws/credentials. Verify how the reusable workflow handles this (e.g. via AWS_PROFILE override or by unsetting the profile in CI).  


GitHub Actions and NodeJS version


If workflow has "runs-on: ubuntu-latest", which Node will be initially installed on the runner?


ubuntu-latest currently (April 2026) maps to ubuntu-24.04, which comes with Node 20 pre-installed on the runner image.

This is an important distinction from the deprecation warnings — there are two separate Node versions in play:
  • Actions runner Node — the Node version GitHub uses to execute action JavaScript (like aws-actions/configure-aws-credentials). This is what the warnings are about: GitHub is moving this from Node 20 → Node 24 by default on June 2, 2026.
  • Pre-installed Node on the runner — what's available for your shell scripts/run: steps. Currently Node 20 on ubuntu-24.04.

This is relevant to jobs which use e.g. actions/setup-node@v6.4.0 with node-version: "16" to explicitly install Node 16 for the test run, which overrides the pre-installed Node 20. That's unrelated to the deprecation warning (like Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/setup-node@v3. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
image-scan) and works independently.

So actions themselves are running on their own pre-configured Node version and it can happen that e.g. actions/setup-node runs on Node v24 but it installs Node v16 on the runner?


Exactly. The two things are completely independent:

The action's runtime (runs.using: node24 in the action's action.yml) — the Node version that executes the action's JavaScript code internally. This is what the deprecation warnings are about.

What the action does — actions/setup-node just downloads and installs a Node binary onto the runner's PATH. The version it installs (node-version: "16") has nothing to do with what Node version the action itself runs on.

So yes — actions/setup-node@v6.4.0 runs its own JavaScript on Node 24, but the end result is Node 16 becoming available on the runner for subsequent run: steps.

---

No comments: