Automating GitHub Actions Updates Across Your Entire Organization
The Hidden Cost of Outdated GitHub Actions
If you're managing multiple repositories with GitHub Actions, you've likely faced this scenario: A critical security patch is released for actions/checkout
, and now you need to update it across 50+ repositories. Or worse, you discover you're using a 2-year-old version of an action only when it breaks in production.
Manual checking? That's hours of tedious work. Missed updates? That's potential security vulnerabilities and breaking changes discovered at the worst possible moment.
Today, I'm sharing how we completely automated GitHub Actions updates across our entire organisation using actions-up, custom GitHub Actions, and Terraform.
The Problem at Scale
Managing GitHub Actions updates manually presents several challenges:
- Time Sink: Checking each repository individually is incredibly time-consuming
- Inconsistency: Different repos end up with different action versions
- Security Risks: Outdated actions may contain known vulnerabilities
- Breaking Changes: Major version updates discovered during critical deployments
- Toil: Engineers waste time on repetitive maintenance tasks
Enter actions-up
actions-up by Azat S. is a fantastic tool that scans your GitHub workflows and identifies outdated actions. It can:
- Detect all GitHub Actions in your workflows
- Show available updates with version comparisons
- Distinguish between minor updates and breaking changes
- Optionally update actions automatically
But running this manually still requires someone to remember to do it. That's where automation comes in.
Building the Automation
Step 1: Creating a GitHub Action for PR Checks
I created a GitHub Action that automatically runs on every pull request to check for outdated actions. Here's the complete workflow:
name: Check for outdated GitHub Actions
on:
pull_request:
types: [edited, opened, synchronize, reopened]
jobs:
check-actions:
name: Check for GHA updates
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install actions-up
run: npm install -g actions-up
- name: Run actions-up check
id: actions-check
run: |
echo "## GitHub Actions Update Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Initialise variables
HAS_UPDATES=false
UPDATE_COUNT=0
# Run actions-up and capture output (force no color output)
echo "Running actions-up to check for updates..."
actions-up --dry-run > actions-up-raw.txt 2>&1 || true
# Strip ANSI color codes from the output
sed -i 's/\x1b\[[0-9;]*m//g' actions-up-raw.txt
# Also remove any other control characters
sed -i 's/\x1b\[[0-9;]*[a-zA-Z]//g' actions-up-raw.txt
# Parse the output to detect updates
# Look for patterns like "v3 β v4" or "would be updated"
if grep -E "(β|would be updated|Update available)" actions-up-raw.txt > /dev/null 2>&1; then
HAS_UPDATES=true
# Count the number of updates (lines with arrows)
UPDATE_COUNT=$(grep -c "β" actions-up-raw.txt || echo "0")
fi
# Create formatted output
if [ "$HAS_UPDATES" = true ]; then
echo "β οΈ Found $UPDATE_COUNT GitHub Actions with available updates" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details>" >> $GITHUB_STEP_SUMMARY
echo "<summary>Click to see details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```
{% endraw %}
' >> $GITHUB_STEP_SUMMARY
cat actions-up-raw.txt >> $GITHUB_STEP_SUMMARY
echo '
{% raw %}
```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
# Create detailed markdown report with better formatting
{
echo "## π GitHub Actions Update Report"
echo ""
# Extract summary information
TOTAL_ACTIONS=$(grep -oP 'Found \K[0-9]+(?= actions)' actions-up-raw.txt | head -1 || echo "0")
BREAKING_UPDATES=$(grep -oP '\(([0-9]+) breaking\)' actions-up-raw.txt | grep -oP '[0-9]+' || echo "0")
echo "### Summary"
echo "- **Total actions scanned:** $TOTAL_ACTIONS"
echo "- **Updates available:** $UPDATE_COUNT"
if [ "$BREAKING_UPDATES" != "0" ]; then
echo "- **β οΈ Breaking changes:** $BREAKING_UPDATES"
fi
echo ""
echo "### π Available Updates"
echo ""
# Format the updates in a table
echo "| Workflow File | Action | Current | Available | Type | Release Notes |"
echo "|--------------|--------|---------|-----------|------|---------------|"
# Parse each update line
grep "β" actions-up-raw.txt | while IFS= read -r line; do
# Extract workflow file path (remove leading path)
if echo "$line" | grep -q "\.github/workflows/"; then
PREV_FILE=$(echo "$line" | grep -oP '\.github/workflows/[^:]+' | head -1)
fi
# Skip file path lines, process only action updates
if echo "$line" | grep -q ": .* β "; then
# Extract action name and versions
ACTION=$(echo "$line" | cut -d: -f1 | xargs)
CURRENT=$(echo "$line" | grep -oP 'v[0-9]+(\.[0-9]+)*' | head -1)
NEW=$(echo "$line" | grep -oP 'β \Kv[0-9]+(\.[0-9]+)*' | head -1)
# Determine if it's a breaking change
CURRENT_MAJOR=$(echo "$CURRENT" | grep -oP 'v\K[0-9]+' || echo "0")
NEW_MAJOR=$(echo "$NEW" | grep -oP 'v\K[0-9]+' || echo "0")
if [ "$CURRENT_MAJOR" != "$NEW_MAJOR" ]; then
TYPE="β οΈ Breaking"
# Generate release URL
if echo "$ACTION" | grep -q "/"; then
REPO_PATH="$ACTION"
else
REPO_PATH="actions/$ACTION"
fi
RELEASE_URL="https://github.com/${REPO_PATH}/releases/tag/${NEW}"
RELEASE_LINK="[π Release](${RELEASE_URL})"
else
TYPE="β
Minor"
RELEASE_LINK="-"
fi
# Output table row
WORKFLOW_NAME=$(basename "$PREV_FILE" 2>/dev/null || echo "workflow.yml")
echo "| \`$WORKFLOW_NAME\` | $ACTION | $CURRENT | **$NEW** | $TYPE | $RELEASE_LINK |"
fi
done
echo ""
echo "### π§ How to Update"
echo ""
echo "You have several options to update these actions:"
echo ""
echo "#### Option 1: Automatic Update (Recommended)"
echo '```
{% endraw %}
bash'
echo "# Run this command locally in your repository"
echo "npx actions-up"
echo '
{% raw %}
```'
echo ""
echo "#### Option 2: Manual Update"
echo "1. Review each update in the table above"
echo "2. For β οΈ breaking changes, click the Release Notes link to review changes"
echo "3. Edit the workflow files and update the version numbers"
echo "4. Test the changes in your CI/CD pipeline"
echo ""
echo "#### Option 3: Selective Update"
echo '```
{% endraw %}
bash'
echo "# Update only non-breaking changes"
echo "npx actions-up --breaking false"
echo '
{% raw %}
```'
echo ""
if [ "$BREAKING_UPDATES" != "0" ]; then
echo "### β οΈ Breaking Changes Warning"
echo ""
echo "This update includes **$BREAKING_UPDATES breaking change(s)**. Please review the release notes before updating:"
echo ""
grep "β" actions-up-raw.txt | while IFS= read -r line; do
if echo "$line" | grep -q ": .* β "; then
ACTION=$(echo "$line" | cut -d: -f1 | xargs)
CURRENT=$(echo "$line" | grep -oP 'v[0-9]+' | head -1)
NEW=$(echo "$line" | grep -oP 'β \Kv[0-9]+(\.[0-9]+)*' | head -1)
CURRENT_MAJOR=$(echo "$CURRENT" | grep -oP '[0-9]+' || echo "0")
NEW_MAJOR=$(echo "$NEW" | grep -oP '[0-9]+' || echo "0")
if [ "$CURRENT_MAJOR" != "$NEW_MAJOR" ]; then
if echo "$ACTION" | grep -q "/"; then
REPO_PATH="$ACTION"
else
REPO_PATH="actions/$ACTION"
fi
RELEASE_URL="https://github.com/${REPO_PATH}/releases/tag/${NEW}"
echo "- **$ACTION**: $CURRENT β $NEW - [View Release Notes](${RELEASE_URL})"
fi
fi
done
echo ""
echo "**Important:** Breaking changes may require modifications to your workflow configuration. Always review the release notes and test thoroughly."
echo ""
fi
echo "---"
echo ""
echo "<details>"
echo "<summary>π Raw actions-up output</summary>"
echo ""
echo '```
{% endraw %}
'
cat actions-up-raw.txt
echo '
{% raw %}
```'
echo "</details>"
} > actions-up-report.md
echo "has-updates=true" >> $GITHUB_OUTPUT
echo "update-count=$UPDATE_COUNT" >> $GITHUB_OUTPUT
else
echo "β
All GitHub Actions are up to date!" >> $GITHUB_STEP_SUMMARY
{
echo "## β
GitHub Actions Update Report"
echo ""
echo "### All GitHub Actions in this repository are up to date!"
echo ""
echo "No action required. Your workflow files are using the latest versions of all GitHub Actions."
} > actions-up-report.md
echo "has-updates=false" >> $GITHUB_OUTPUT
echo "update-count=0" >> $GITHUB_OUTPUT
fi
- name: Comment PR with updates
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('actions-up-report.md', 'utf8');
const hasUpdates = '${{ steps.actions-check.outputs.has-updates }}' === 'true';
const updateCount = '${{ steps.actions-check.outputs.update-count }}';
// Check if we already commented
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('GitHub Actions Update Report')
);
const commentBody = `${report}
---
*π€ Generated using [actions-up](https://github.com/azat-io/actions-up) | Last check: ${new Date().toISOString()}*`;
// Only comment if there are updates or if we previously commented
if (hasUpdates || botComment) {
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment only if there are updates
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});
console.log('Created new comment');
}
} else {
console.log('No updates found and no previous comment exists - skipping comment');
}
// Add or update PR labels based on status
const labels = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const hasOutdatedLabel = labels.data.some(label => label.name === 'outdated-actions');
if (hasUpdates && !hasOutdatedLabel) {
// Add label if updates are found
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['outdated-actions']
});
console.log('Added outdated-actions label');
} catch (error) {
console.log('Could not add label (might not exist in repo):', error.message);
}
} else if (!hasUpdates && hasOutdatedLabel) {
// Remove label if no updates
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'outdated-actions'
});
console.log('Removed outdated-actions label');
} catch (error) {
console.log('Could not remove label:', error.message);
}
}
- name: Fail if outdated actions found
if: steps.actions-check.outputs.has-updates == 'true'
run: |
echo "::error::π¨ Found ${{ steps.actions-check.outputs.update-count }} outdated GitHub Actions. Please update them before merging. π¨"
echo ""
echo "You can update them by running: npx actions-up"
echo "Or manually update the versions in your workflow files."
exit 1
Key Features of This Workflow
- Automatic PR Checks: Runs on every pull request automatically
-
Detailed Reporting: Creates a formatted markdown report with:
- Summary of total actions and available updates
- Table showing current vs. available versions
- Clear marking of breaking changes
- Direct links to release notes
- PR Comments: Automatically comments on the PR with update details
-
PR Labels: Adds an
outdated-actions
label when updates are found - CI Integration: Fails the check if outdated actions are found, preventing merge
Step 2: Deploying at Scale with Terraform
The real magic happens when you need to deploy this across dozens or hundreds of repositories. We use Terraform to manage our GitHub repositories, making this deployment trivial.
Here's our Terraform configuration:
variable "GH-ADMIN-TOKEN" {
type = string
default = ""
}
terraform {
required_version = ">= 1.12.1"
required_providers {
github = {
source = "integrations/github"
version = ">= 6.6.0"
}
}
}
# Generate a list of all repositories of your organisation
data "github_repositories" "all_repos" {
query = "org:myghorganisationname archived:false"
}
locals {
status_checks = [
"Jira Ticket in PR",
"Tasks Completed",
"Task Completed Checker"
]
# Get all repositories from GitHub
github_repos_as_map = {
for repo in data.github_repositories.all_repos.names : repo => {}
}
# Merge with any custom configurations
merged_repositories_map = merge(
local.github_repos_as_map,
local.repositories_with_custom_config
)
# Filter out repositories that don't need PR protection
protected_repos = {
for repo, config in local.merged_repositories_map :
repo => config if !contains(keys(local.repos_without_pr_protection), repo)
}
}
# Deploy the GitHub Actions update checker to all protected repositories
resource "github_repository_file" "github_actions_check_updates" {
for_each = local.protected_repos
repository = each.key
branch = "main"
file = ".github/workflows/upgrade-actions.yml"
content = file("${path.module}/upgrade-actions.yml")
commit_message = "GitHub Actions Upgrade Check"
commit_author = "Ops Team"
commit_email = "admin@mycompany.com"
overwrite_on_create = true
}
How the Terraform Configuration Works
- Repository Discovery: Automatically discovers all repositories in your organisation
- Filtering: Applies the workflow only to repositories with PR protection enabled
- Bulk Deployment: Deploys the workflow file to all selected repositories in one terraform apply
- Consistency: Ensures all repositories have the exact same version of the check
- Version Control: The workflow file itself is version-controlled alongside your Terraform code
The Results
After implementing this solution, we've seen:
- 100% Coverage: Every protected repository automatically checks for outdated actions
- Zero Manual Work: No engineer needs to manually check for updates
- Immediate Feedback: Developers see outdated actions before merging their PRs
- Security Improvements: Critical security updates are never missed
- Time Savings: What used to take hours now happens automatically
Example PR Comment
When a PR contains outdated actions, developers see a comment like this:
The comment provides everything needed to make an informed decision about updating.
Best Practices
- Start with Non-Critical Repos: Test the workflow on less critical repositories first
- Customise Status Checks: Decide whether outdated actions should block PR's or just warn
- Review Breaking Changes: Always review release notes for major version updates
- Regular Updates: Consider scheduling regular update PRs using GitHub Actions
- Monitor False Positives: Some actions might show as outdated when they're intentionally pinned
Advanced: Scheduled Updates
You can extend this further with a scheduled workflow that creates PR's automatically:
name: Weekly Actions Update Check
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9 AM
workflow_dispatch:
jobs:
update-actions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Update actions
run: |
npx actions-up --breaking false
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: update GitHub Actions (non-breaking)'
title: 'chore: Update GitHub Actions (non-breaking changes)'
body: |
This PR updates GitHub Actions to their latest non-breaking versions.
Generated automatically by actions-up.
branch: automated-actions-update
Conclusion
Automating GitHub Actions updates might seem like a small optimisation, but at scale, it's transformative. By combining:
- actions-up for detection
- GitHub Actions for automation
- Terraform for deployment at scale
We've eliminated an entire category of maintenance work while improving security and consistency across our entire organisation.
The setup takes less than an hour, but the time savings are measured in hundreds of engineer-hours per year.
More importantly, it's one less thing for engineers to worry about, letting them focus on what matters: building great software, letting no burden to the Ops team.
Resources
What repetitive tasks have you automated in your CI/CD pipeline? Share your experiences in the comments!
Top comments (1)
π‘π‘