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=truein.npmrcto 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:
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:
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:
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: bashYour 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 fiUsing 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 buildNotice 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:
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.
