Automating Library Updates with GitHub Actions

Update C++ Library Dependencies in R Packages

github
github-actions
r
r-package
c++
gitlab
automation
bandicoot
Author

James Balamuta

Published

February 26, 2025

Editor’s Note

There is an older version of this post discussing how to automate dependency updates using GitHub Actions when the upstream dependency is on GitHub. This post focuses on the scenario where the upstream dependency is on GitLab but the main project is on GitHub.

One of the most persistent challenges in software development is keeping dependencies current. When projects rely on external libraries – like an R package wrapping a C++ library – staying updated with the latest releases is essential for security, performance improvements, and new features. However, manually checking for updates and integrating them can be tedious and error-prone.

In our case, we faced a specific challenge: we needed to automatically track releases of the Bandicoot library (hosted on GitLab) and update our local copy of its header files (hosted on GitHub). This is a common scenario for C++ projects that vendor dependencies rather than using package managers.

The manual process typically involved:

  1. Checking the GitLab repository for new releases
  2. Downloading the release archive
  3. Extracting the required include directory
  4. Updating our project’s inst/include directory
  5. Updating a version file to track which version we were using
  6. Running tests to ensure the update didn’t break anything

This process was:

So, the question arises: how can we improve a necessary maintenance task?

Automation with GitHub Actions

We implemented a GitHub Actions workflow specifically designed for R packages with C++ dependencies. This solution offers advantages that general-purpose dependency managers like Dependabot cannot provide for cross-language dependencies.

Our workflow performs these key tasks:

  1. Scheduled checks: Runs a daily check at 10 AM PDT for new Bandicoot releases
  2. Version comparison: Compares current and latest versions to avoid unnecessary updates and changes
  3. Automated extraction: Downloads and extracts only the relevant C++ headers
  4. Pull request creation: Creates a pull request rather than modifying the main branch directly
  5. Integration with R package testing: Triggers R CMD check to verify the R bindings work with updated C++ headers
  6. Clear changelog documentation: The PR includes details about what version was updated and what changed

Visually, the workflow looks like this:

flowchart TD
    A[Schedule or Manual Trigger] --> B[Check Current Version]
    B --> C{New Version<br/>Available?}
    C -->|No| D[End Workflow]
    C -->|Yes| E[Download Release<br />from GitLab]
    E --> F[Extract C++ Headers]
    F --> G[Update Files in inst/include]
    G --> H[Update version.txt]
    H --> I[Create Pull Request]
    I --> J[Trigger R CMD check]
    J --> K{Tests Pass?}
    K -->|Yes| L[Ready for Review]
    K -->|No| M[Fix Issues]
    M --> I
    
    style C decision
    style K decision
    style D terminal
    style L terminal
Figure 1: GitHub Actions Workflow for Automating Dependency Updates

Prerequisite GitLab API Key in Repository Secrets

For the workflow to run properly, you’ll need to add a secret in your GitHub repository to authenticate with the GitLab API. Setting up this secret involves:

  1. Go to your Repository > Settings > Secrets and variables > Actions (guide)
  2. Add a new repository secret named GITLAB_API_TOKEN with your GitLab personal access token (PAT)
    • Token must have the api scope and be set as guest.
    • By default, the token will expire within 1 year of the issuance.

Step-by-Step Workflow Implementation

Once the prerequisites are in place, you can implement the workflow in your repository. We’ll break down the steps involved and explain the rationale behind each one. We’ve place our workflow in a file named upstream-update.yml in the .github/workflows directory.

To kick things off, we define the workflow triggers, job structure, environment, and retrieve the current version:

name: Fetch Latest Bandicoot Release

on:
  workflow_dispatch: {} # Manual trigger
  schedule:
    - cron: '0 17 * * *'  # Runs daily at 10 AM PDT (5 PM UTC)

jobs:
  fetch-gitlab-release:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
        
      - name: Set up GitLab API access
        id: setup
        run: |
          echo "GITLAB_API_URL=https://gitlab.com/api/v4" >> $GITHUB_ENV
          echo "GITLAB_PROJECT_ID=bandicoot-lib/bandicoot-code" >> $GITHUB_ENV
          
      - name: Get current version
        id: current-version
        run: |
          if [ -f "inst/version.txt" ]; then
            CURRENT_VERSION=$(cat inst/version.txt)
            echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
            echo "Current version: $CURRENT_VERSION"
          else
            echo "current_version=none" >> $GITHUB_OUTPUT
            echo "No current version found"
          fi

Next, we fetch the latest release information from GitLab using the API and manipulate the response using jq to extract the version and download URL:

- name: Fetch latest release from GitLab
  id: get-version
  run: |
    # Fetch latest release from GitLab API
    RESPONSE=$(curl -s "$GITLAB_API_URL/projects/$(echo $GITLAB_PROJECT_ID | sed 's/\//%2F/g')/releases" \
      -H "PRIVATE-TOKEN: ${{ secrets.GITLAB_API_TOKEN }}")
      
    # Extract the latest version and download URL
    LATEST_VERSION=$(echo $RESPONSE | jq -r '.[0].tag_name')
    DOWNLOAD_URL=$(echo $RESPONSE | jq -r '.[0].assets.sources[0].url')
    
    # Remove 'v' prefix if present
    LATEST_VERSION=${LATEST_VERSION#v}
    
    # Set as output and environment variable
    echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT
    echo "download_url=$DOWNLOAD_URL" >> $GITHUB_OUTPUT
    echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV
    
    # Log the fetched version
    echo "Latest version: $LATEST_VERSION"
    echo "Download URL: $DOWNLOAD_URL"

We then compare the current and latest versions to determine if an update is needed:

- name: Check if update is needed
  id: check-update
  run: |
    if [ "${{ steps.current-version.outputs.current_version }}" != "${{ steps.get-version.outputs.version }}" ]; then
      echo "update_needed=true" >> $GITHUB_OUTPUT
      echo "Update needed: Current version ${{ steps.current-version.outputs.current_version }} differs from latest ${{ steps.get-version.outputs.version }}"
    else
      echo "update_needed=false" >> $GITHUB_OUTPUT
      echo "No update needed: Current version matches latest version"
    fi

Now, we download the release archive, extract the required files, and update our project’s inst/include directory:

- name: Download and extract release
  if: steps.check-update.outputs.update_needed == 'true'
  run: |
    # Create temporary directory
    mkdir -p temp_download
    
    # Download the zip file
    curl -L "${{ steps.get-version.outputs.download_url }}" -o temp_download/release.zip
    
    # Extract the zip file
    unzip temp_download/release.zip -d temp_download
    
    # Find the bandicoot directory
    BANDICOOT_DIR=$(find temp_download -name "bandicoot-code-*" -type d | head -1)
    echo "Found Bandicoot directory: $BANDICOOT_DIR"
    
    # Check if include directory exists
    if [ -d "$BANDICOOT_DIR/include" ]; then
      echo "Found include directory at $BANDICOOT_DIR/include"
      
      # Create destination directory if it doesn't exist
      mkdir -p inst/include
      
      # Copy the include directory contents
      cp -r $BANDICOOT_DIR/include/* inst/include/
      
      # List copied files for verification
      echo "Copied files to inst/include:"
      ls -la inst/include/
    else
      echo "Error: include directory not found in $BANDICOOT_DIR"
      echo "Directory contents:"
      ls -la $BANDICOOT_DIR
      exit 1
    fi

And finally, we create a pull request using Peter Evans’ Create Pull Request Action instead of directly pushing changes:

      - name: Create Pull Request
        if: steps.check-update.outputs.update_needed == 'true'
        uses: peter-evans/create-pull-request@v5
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "Update to Bandicoot release ${{ steps.get-version.outputs.version }}"
          title: "Update Bandicoot to version ${{ steps.get-version.outputs.version }}"
          body: |
            This PR updates the Bandicoot library from version ${{ steps.current-version.outputs.current_version }} to ${{ steps.get-version.outputs.version }}.
            
            Changes:
            - Updated `inst/include/` files from the latest release
            - Updated version in `inst/version.txt`
            
            This PR was automatically generated by the Fetch Latest Bandicoot Release workflow.
          branch: update-bandicoot-${{ steps.get-version.outputs.version }}
          base: main
          labels: |
            dependency
            automated pr

You can find all steps combined together at the end of the post or on the GitHub repository

Why This Matters: Beyond Convenience

While the immediate benefit is saving time and reducing manual effort, the implications of this automation go much deeper:

Automated Testing of Dependency Changes

One of the most critical benefits is the ability to automatically test if changes to the underlying C++ dependency cause issues with our R package bindings. By creating a pull request instead of directly pushing changes, we can trigger our CI/CD pipeline to run R CMD check against the updated dependency.

This gives us an automated verification step that catches breaking changes early. If the Bandicoot library introduces API changes or behavior modifications that would break our R wrapper functions, we’ll know immediately from the CI results on the PR - before the changes reach our main branch or any users. This significantly reduces the risk of dependency updates causing unexpected issues in production.

Reduced Technical Debt

Each postponed update increases technical debt. When updates are manual, it’s tempting to delay them, especially for seemingly minor version increments. This leads to situations where eventually, you’re faced with updating across multiple major versions—a much riskier proposition. Automation ensures incrementalism, which is inherently safer.

Improved Developer Experience

The pull request approach means developers don’t need to switch contexts to perform updates. They can review changes within their normal workflow, and the automated PR description provides clear information about what’s being updated. This leads to more consistent code reviews and better understanding of dependency changes.

Reliable Release Cadence

When integrated into CI/CD pipelines, automated dependency updates contribute to a more reliable release cadence. Teams can focus on feature development and bug fixes rather than maintenance tasks, leading to more predictable delivery of value to end-users.

Why Not Use Dependabot?

GitHub’s Dependabot is an excellent tool for managing dependencies declared in standard package management files like package.json, requirements.txt, or Gemfile. However, it has significant limitations that make it unsuitable for our use case:

No Support for Vendored Dependencies

Dependabot only works with dependencies that are declared in supported manifest files. It cannot handle our scenario where we’re extracting specific files from a GitLab repository and placing them in a specific location in our project structure. We’re essentially “vendoring” these files rather than declaring a dependency.

Limited Cross-Repository Support

Dependabot primarily works within the GitHub ecosystem. While it can handle some external registries for known package systems, it doesn’t have built-in support for monitoring GitLab repositories for new releases.

No Custom Processing

Our workflow requires custom processing steps: we download a ZIP file, extract only the include directory, and place it in a specific location. Dependabot offers no way to customize extraction or processing of dependencies.

Cross-Language Requirements

Our specific use case involves C++ headers used within an R package. Dependabot doesn’t handle these cross-language integrations well, especially when custom extraction and placement is needed.

Specific Version File Management

We maintain a version.txt file to track which version we’re using. Dependabot has no mechanism to update arbitrary files like this as part of its update process.

By implementing our custom GitHub Action, we gain complete control over the update process, from detecting new versions to processing files exactly as needed for our R package’s requirements.

Fin

Automating library updates might seem like a small optimization, but it addresses a significant point of friction in modern software development. By leveraging GitHub Actions to automate the update process for our Bandicoot library dependency, we’ve improved our security posture, reduced technical debt, and freed up developer time for more valuable work.

For our R package wrapping a C++ library, this automation is particularly valuable as it allows us to automatically run R CMD check on every update. This ensures that changes in the C++ headers don’t silently break our R bindings, giving us early warning of any compatibility issues.

Appendix

Further reading …

Full GitHub Actions Workflow

At the time of writing, the full workflow file being used in the rcppbandicoot repository is as follows:

name: Fetch Latest Bandicoot Release

on:
  workflow_dispatch:  # Manual trigger
  schedule:
    - cron: '0 17 * * *'  # Runs daily at 10 AM PDT (5 PM UTC)

jobs:
  fetch-gitlab-release:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
        
      - name: Set up GitLab API access
        id: setup
        run: |
          echo "GITLAB_API_URL=https://gitlab.com/api/v4" >> $GITHUB_ENV
          echo "GITLAB_PROJECT_ID=bandicoot-lib/bandicoot-code" >> $GITHUB_ENV
          
      - name: Get current version
        id: current-version
        run: |
          if [ -f "inst/version.txt" ]; then
            CURRENT_VERSION=$(cat inst/version.txt)
            echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
            echo "Current version: $CURRENT_VERSION"
          else
            echo "current_version=none" >> $GITHUB_OUTPUT
            echo "No current version found"
          fi
          
      - name: Fetch latest release from GitLab
        id: get-version
        run: |
          # Fetch latest release from GitLab API
          RESPONSE=$(curl -s "$GITLAB_API_URL/projects/$(echo $GITLAB_PROJECT_ID | sed 's/\//%2F/g')/releases" \
            -H "PRIVATE-TOKEN: ${{ secrets.GITLAB_API_TOKEN }}")
            
          # Extract the latest version and download URL
          LATEST_VERSION=$(echo $RESPONSE | jq -r '.[0].tag_name')
          DOWNLOAD_URL=$(echo $RESPONSE | jq -r '.[0].assets.sources[0].url')
          
          # Remove 'v' prefix if present
          LATEST_VERSION=${LATEST_VERSION#v}
          
          # Set as output and environment variable
          echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT
          echo "download_url=$DOWNLOAD_URL" >> $GITHUB_OUTPUT
          echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_ENV
          
          # Log the fetched version
          echo "Latest version: $LATEST_VERSION"
          echo "Download URL: $DOWNLOAD_URL"
      
      - name: Check if update is needed
        id: check-update
        run: |
          if [ "${{ steps.current-version.outputs.current_version }}" != "${{ steps.get-version.outputs.version }}" ]; then
            echo "update_needed=true" >> $GITHUB_OUTPUT
            echo "Update needed: Current version ${{ steps.current-version.outputs.current_version }} differs from latest ${{ steps.get-version.outputs.version }}"
          else
            echo "update_needed=false" >> $GITHUB_OUTPUT
            echo "No update needed: Current version matches latest version"
          fi
      
      - name: Download and extract release
        if: steps.check-update.outputs.update_needed == 'true'
        run: |
          # Create temporary directory
          mkdir -p temp_download
          
          # Download the zip file
          curl -L "${{ steps.get-version.outputs.download_url }}" -o temp_download/release.zip
          
          # Extract the zip file
          unzip temp_download/release.zip -d temp_download
          
          # Find the bandicoot directory
          BANDICOOT_DIR=$(find temp_download -name "bandicoot-code-*" -type d | head -1)
          echo "Found Bandicoot directory: $BANDICOOT_DIR"
          
          # Check if include directory exists
          if [ -d "$BANDICOOT_DIR/include" ]; then
            echo "Found include directory at $BANDICOOT_DIR/include"
            
            # Create destination directory if it doesn't exist
            mkdir -p inst/include
            
            # Copy the include directory contents
            cp -r $BANDICOOT_DIR/include/* inst/include/
            
            # List copied files for verification
            echo "Copied files to inst/include:"
            ls -la inst/include/
          else
            echo "Error: include directory not found in $BANDICOOT_DIR"
            echo "Directory contents:"
            ls -la $BANDICOOT_DIR
            exit 1
          fi
          
          # Clean up
          rm -rf temp_download
          
          # Update version file
          mkdir -p inst
          echo "${{ steps.get-version.outputs.version }}" > inst/version.txt
          
      - name: Create Pull Request
        if: steps.check-update.outputs.update_needed == 'true'
        uses: peter-evans/create-pull-request@v7
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "Update to Bandicoot release ${{ steps.get-version.outputs.version }}"
          title: "Update Bandicoot to version ${{ steps.get-version.outputs.version }}"
          body: |
            This PR updates the Bandicoot library from version ${{ steps.current-version.outputs.current_version }} to ${{ steps.get-version.outputs.version }}.
            
            Changes:
            - Updated `inst/include/` files from the latest release
            - Updated version in `inst/version.txt`
            
            This PR was automatically generated by the Fetch Latest Bandicoot Release workflow.
          branch: update-bandicoot-${{ steps.get-version.outputs.version }}
          base: main
          labels: |
            dependency
            automated pr