This document contains best practices for creating secure, reusable workflows.
Written as an extension of Security hardening for GitHub Actions. You should know the contents of that document.
-
Don't create reusable workflows containing a single step. This adds unnecessary complexity to the workflow chain.
-
Workflows should depend on source code, e.g. using the
actions/checkout
oractions/download-artifact
actions.For workflows that don't depend on source code, consider using another automation system instead, e.g. Azure Automation for automating tasks in Azure.
-
Don't create conditionals based on the event that triggered the workflow, for example:
jobs: example-job: if: github.event_type == 'pull_request'
This reduces the flexibility of the reusable workflow.
-
Disable top level GitHub token permissions, then enable required permissions at the job level instead:
permissions: {} jobs: example-job: runs-on: ubuntu-latest permissions: contents: read # Required to checkout the repository steps: - name: Checkout uses: actions/checkout@v4
This ensures that workflows follow the principle of least privilege.
-
When using a third-party action, pin it to a specific commit SHA, for example:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
-
Jobs that access secrets that grant privileged access (for example
Contributor
access in an Azure subscription) should be skipped if the workflow was triggered by Dependabot:jobs: example-job: runs-on: ubuntu-latest if: github.actor != 'dependabot[bot]' steps: - name: Login to Azure uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }}
This is to prevent Dependabot from updating a dependency to a version containing malicious code, then automatically running that code in our workflow, allowing it to steal your secrets.
Jobs that access secrets that grant non-privileged access (for example
Reader
access in an Azure subscription) should not be skipped if the workflow was triggered by Dependabot. In this scenario, separate Dependabot secrets must be created in the repository containing the caller workflow (see official documentation). -
Set a specific runner OS version for all jobs (see supported GitHub-hosted runners):
jobs: example-job: runs-on: ubuntu-24.04
This ensures that all jobs are executed on a runner that includes the required software by default.
-
Workflows that run Azure CLI commands should declare the following top level environment variable to disable output from Azure CLI commands by default:
env: AZURE_CORE_OUTPUT: none
This is to prevent Azure CLI commands from outputting secrets. Output can be explicitly enabled for commands that require it by using the
--output
global parameter.
-
Use kebab-case for workflow filenames, job identifiers and step identifiers.
-
Use snake_case for input and output identifiers.
-
Use SCREAMING_SNAKE_CASE for environment variable names.
-
A reusable workflow and its main job should be named after the main tool/service that is used, for example:
terraform.yml
docker.yml
azure-webapp.yml
This is to ensure descriptive job names, for example:
- If a caller workflow has a job
provision
that calls the reusable workflowterraform
, the final job will be namedprovision / terraform
. - If a caller workflow has a job
build
that calls the reusable workflowdocker
, the final job will be namedbuild / docker
. - If a caller workflow has a job
deploy
that calls the reusable workflowazure-webapp
, the final job will be nameddeploy / azure-webapp
.
-
An input that is passed to a workflow property should inherit the name of that property.
An input that is passed to an action input should follow the common naming convention
[<action>]_<input>
, where<action>
can be omitted if the name of the action is similar to the name of the workflow.An input that is passed to a CLI command option should follow the common naming convention
[<command>]_<option>
.For example:
# python.yml on: workflow_call: inputs: runs_on: description: The type of machine to run the job on. type: string required: false default: ubuntu-latest python_version: description: The version of Python to use. type: string required: false default: latest pip_install_target: description: The target directory that pip should install packages into. type: string required: false jobs: python: runs-on: ${{ inputs.runs_on }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python_version }} - name: Install requirements env: PIP_INSTALL_TARGET: ${{ inputs.pip_install_target }} run: pip install --requirement requirements.txt --target "$PIP_INSTALL_TARGET"
-
Workflows that upload or download an artifact must have an input
artifact_name
that specifies the name of the artifact to be uploaded or downloaded. -
Don't upload multiple files to an artifact. Collect the files in a tarball and upload that instead:
- name: Create tarball id: tar env: ARTIFACT_NAME: ${{ inputs.artifact_name }} run: | tarball="$RUNNER_TEMP/$ARTIFACT_NAME.tar" tar --create --file "$tarball" . echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ inputs.artifact_name }} path: ${{ steps.tar.outputs.tarball }}
This will drastically improve upload performance, as the
actions/upload-artifact
action will only need to make a single request to the GitHub API to upload the tarball, instead of multiple requests to upload each individual file. -
Workflows that download an artifact must extract the tarball:
- name: Download artifact uses: actions/download-artifact@v4 with: name: ${{ inputs.artifact_name }} - name: Extract tarball env: ARTIFACT_NAME: ${{ inputs.artifact_name }} run: | tarball="$ARTIFACT_NAME.tar" tar --extract --file "$tarball" rm "$tarball"