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
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:
- Checking the GitLab repository for new releases
- Downloading the release archive
- Extracting the required
include
directory - Updating our project’s
inst/include
directory - Updating a version file to track which version we were using
- Running tests to ensure the update didn’t break anything
This process was:
- Time-consuming
- Easy to overlook or forget
- Prone to human error
- Disruptive to development workflow
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:
- Scheduled checks: Runs a daily check at 10 AM PDT for new Bandicoot releases
- Version comparison: Compares current and latest versions to avoid unnecessary updates and changes
- Automated extraction: Downloads and extracts only the relevant C++ headers
- Pull request creation: Creates a pull request rather than modifying the main branch directly
- Integration with R package testing: Triggers
R CMD check
to verify the R bindings work with updated C++ headers - Clear changelog documentation: The PR includes details about what version was updated and what changed
Visually, the workflow looks like this:
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:
- Go to your Repository > Settings > Secrets and variables > Actions (guide)
- 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.
- Token must have the
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
Link with CI Testing
To fully leverage the automated testing capabilities, ensure your repository has a CI workflow that runs on pull requests. For an R package, you might have a .github/workflows/r-cmd-check.yaml
file that runs R CMD check when PRs are created. This can be as simple as:
::use_github_action("check-release") usethis
Or, by manually creating a workflow file from the R-CMD-check GitHub Action.
This way, every Bandicoot update will trigger a CI run to verify that the R package still builds and passes tests.
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