Ken Muse

Configuring GitHub Runners With a Dotfiles Action


A few years ago, I wrote about the magic of dotfiles and how they can personalize your development environments. Whether you’re using Dev Containers, Codespaces, or your local machine, a dotfiles repository ensures your favorite configurations follow you everywhere. But what if you could take that same concept and apply it to GitHub Actions runners?

In my recent article on how I avoided Shai-Hulud’s second coming, I discussed how certain configurations – like disabling npm lifecycle scripts – can protect your environment from supply chain attacks. Those same configurations are equally valuable on GitHub runners, where your CI/CD pipelines execute code and have access to secrets.

By turning a dotfiles repository into a composite action, you can apply your security configurations to any GitHub runner with a single step. Let’s explore how to do this and why it’s so powerful.

Why configure runners with dotfiles?

GitHub-hosted runners come with a standard configuration that works for most use cases. However, there are some aspects where you may want more control over the build environment and its settings:

  • Security hardening
    Apply configurations like ignore-scripts=true in .npmrc to protect against supply chain attacks when restoring dependencies
  • Consistent tooling
    Ensure Git aliases, shell configurations, specific tools are available in your workflows
  • Custom defaults
    Set preferred package manager configurations, environment variables, Git settings, and more

You could add these configurations directly in each workflow file, but that approach has drawbacks. It creates duplication across repositories, makes updates cumbersome, and scatters security-critical settings throughout your organization. You could also burn them into a custom image, but that adds maintenance overhead and requires you to pay the cost for storing the image. Additionally, you lose flexibility since any change requires rebuilding and redeploying the image. Incorporated into the image, it’s also more challenging to track which version of the configuration is in use.

The easiest way to make something available consistently across all your workflows is to create an Action that applies the settings. This ensures that your configurations are centralized, versioned, and reusable.

The traditional approach has limits

You might think that that the obvious solution doesn’t require an Action. You could just add a step that clones the dotfiles repository and runs the install script:

 1   - name: Clone dotfiles
 2     run: |
 3       git clone https://github.com/yourusername/dotfiles.git
 4       cd dotfiles && ./install.sh

This works if your dotfiles repository is public. But there’s a catch – if the repository is internal or private, GitHub will require a token. When a runner starts, it is provided with a token that only has access to the current repository. It cannot access other private repositories without additional authentication. Managing a token adds complexity and can introduce security risks if not handled properly.

Thankfully, there’s a better way.

Creating a composite action in your dotfiles repo

GitHub Actions workflows can reference Actions stored in any repository, and when they do, they automatically have access to that repository’s contents without requiring a separate token. This is why making the repository into an Action provides an elegant solution. The contents of the repository are now accessible to the workflow without additional authentication, making it easy to clone and run the scripts. The process isn’t difficult. You just need to add an action.yml file to your repository to transform it into a reusable, “composite Action”.

Here’s a minimal example. In your dotfiles repository, create an action.yml file at the root:

 1   name: 'Apply Dotfiles'
 2   description: 'Applies dotfiles configuration to the runner environment'
 3   runs:
 4     using: "composite"
 5     steps:
 6       - name: Run install script
 7         run: ${{ github.action_path }}/install.sh
 8         shell: bash

The key here is github.action_path. When the runner executes a job, it downloads all of the required Actions. As each Action is invoked, this variable is populated with its location. The variable allows you to reference files within the Action’s repository, regardless of where the code is located on the runner.

A more complete example

Let’s build out a more robust Action that handles common dotfiles patterns. Assuming your dotfiles repository has this structure:

 1   /
 2   ├── action.yml
 3   ├── install.sh
 4   ├── .bashrc
 5   ├── .gitconfig
 6   ├── .npmrc
 7   ├── .yarnrc.yml

Here’s an action.yml that provides flexibility:

  1   name: 'Apply Dotfiles'
  2   description: 'Applies dotfiles configuration to the runner environment'
  3   inputs:
  4     link-files:
  5       description: 'Whether to symlink files (true) or copy them (false)'
  6       required: false
  7       default: 'false'
  8   runs:
  9     using: "composite"
 10     steps:
 11       - name: Apply dotfiles configuration
 12         run: |
 13           export DOTFILES_LINK="${{ inputs.link-files }}"
 14           ${{ github.action_path }}/install.sh
 15         shell: bash

Your install.sh script might look like this:

  1   #!/bin/bash
  2   set -e
  3   
  4   SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  5   TARGET_DIR="${HOME}"
  6   LINK_FILES="${DOTFILES_LINK:-true}"
  7   if [[ "${CI}" == "true" ]]; then
  8       echo "::group::Applying dotfiles"
  9   fi
 10   
 11   echo "Applying dotfiles to $TARGET_DIR"
 12   
 13   for file in .[!.]*; do
 14       if [ "$file" != ".git" ]; then
 15         if [[ -f "$SCRIPT_DIR/$file" ]]; then
 16             if [[ "$LINK_FILES" == "true" ]]; then
 17                 ln -sf "$SCRIPT_DIR/$file" "$TARGET_DIR/$file"
 18                 echo "Linked $file"
 19             else
 20                 cp -f "$SCRIPT_DIR/$file" "$TARGET_DIR/$file"
 21                 echo "Copied $file"
 22             fi
 23         fi
 24       fi
 25   done
 26   
 27   echo "Dotfiles applied successfully"
 28   
 29   if [[ "${CI}" == "true" ]]; then
 30       echo "::endgroup::"
 31   fi

Using the action in workflows

Once your dotfiles repository has an action.yml, using it is straightforward:

  1   name: Build
  2   
  3   on: [push, pull_request]
  4   
  5   jobs:
  6     build:
  7       runs-on: ubuntu-latest
  8       steps:
  9         - uses: actions/checkout@v4
 10   
 11         - name: Apply security configurations
 12           uses: myrepo/dotfiles@v1
 13   
 14         - name: Install dependencies
 15           run: npm install  # Now protected by .npmrc settings
 16   
 17         - name: Build
 18           run: npm run build

Notice that there’s no token configuration, no secrets to manage, and no cloning step. GitHub handles the checkout of your action automatically.

Securing the action with versions

Just like any GitHub Action, you should use version your Action with tags to ensure stability. For example, you might create a tag v1.0.0 after testing your configurations. When you later make an update, you can create a new tag v1.1.0. This way, workflows can choose when to upgrade to the latest configurations. Also, consider using immutable releases to prevent tampering with your action versions. It ensures that once a version is published, it cannot be modified.

The benefits

This pattern offers several advantages over other approaches:

  • No token management
    GitHub automatically has access to checkout the action, even from private repositories within the same organization. You don’t need to create, store, or rotate PATs (or GitHub App credentials).
  • Centralized security
    Your security configurations live in one place. Update them once, and all workflows using that version benefit. If there’s a critical change needed to secure the runner, you can roll out updates quickly.
  • Versioned configurations
    Use Git tags to manage different versions of your configurations. You can test new settings in some workflows while others use stable versions.
  • Auditable
    Workflow logs clearly show which version of the action was used, making it easy to trace configuration changes.
  • Flexible
    While I’ve shown how to implement this with GitHub Actions, the same concept can be applied to other CI/CD systems that support reusable components or templates, including GitLab, Jenkins, and CircleCI.

The limits

The biggest limitation in this approach is that all of your workflows will need to remember to include the Action as the first step. That means there is still some manual work involved in adopting this pattern, but it is minimal compared to the security benefits and the ability to know which configuration version is in use.

If you’re using custom images, you can slightly improve the performance with an Actions archive cache. This allows the runner to cache frequently used Actions so that it doesn’t need to download them on every run.

Protecting against supply chain attacks

Remember the Shai-Hulud attack I mentioned earlier? One of my defenses was disabling npm and Yarn lifecycle scripts. With a dotfiles action, you can ensure this protection is consistently applied:

 1   # .npmrc
 2   ignore-scripts=true
 3   save-exact=true
 4   audit=true
 5   audit-level=high
 1   # .yarnrc.yml
 2   enableScripts: false
 3   enableImmutableInstalls: true

By applying these configurations at the start of your workflow, the build will no longer execute lifecycle scripts by default. Even if a compromised package is installed, its malicious scripts won’t execute. Instead, users will need to explicitly enable scripts, making it less likely that an attack will succeed unnoticed.

You may also notice that for npm, I added audit=true in this example. That setting audits dependencies during package installation and fails if vulnerabilities above the specified level (audit-level=high) are found.

Since this is the user-level setting, it is the default behavior for all npm and yarn commands run during the workflow. Individual projects and builds can still override these settings if needed, so it provides a good balance between security and flexibility. It doesn’t prevent legitimate use cases, but it makes accidents less likely. It creates guardrails for your CI/CD pipelines rather than rigid walls.

Wrapping up

Dotfiles have always been about bringing your preferred environment with you. By adding a composite action to your dotfiles repository, you extend that convenience to GitHub Actions workflows. Your organization or team can define a preferred default environment for your processes. Since this just relies on Git, this repository can even become an upstream baseline for your own dotfiles repository.

It’s a small investment that pays dividends in security and maintainability. Your runners deserve the same care you put into your local development environment.