DEV Community

Anderson Leite
Anderson Leite

Posted on

Automating GitHub Actions Updates Across Your Entire Organization

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
Enter fullscreen mode Exit fullscreen mode

Key Features of This Workflow

  1. Automatic PR Checks: Runs on every pull request automatically
  2. 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
  3. PR Comments: Automatically comments on the PR with update details
  4. PR Labels: Adds an outdated-actions label when updates are found
  5. 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
}
Enter fullscreen mode Exit fullscreen mode

How the Terraform Configuration Works

  1. Repository Discovery: Automatically discovers all repositories in your organisation
  2. Filtering: Applies the workflow only to repositories with PR protection enabled
  3. Bulk Deployment: Deploys the workflow file to all selected repositories in one terraform apply
  4. Consistency: Ensures all repositories have the exact same version of the check
  5. 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

  1. Start with Non-Critical Repos: Test the workflow on less critical repositories first
  2. Customise Status Checks: Decide whether outdated actions should block PR's or just warn
  3. Review Breaking Changes: Always review release notes for major version updates
  4. Regular Updates: Consider scheduling regular update PRs using GitHub Actions
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
wassimbzn profile image
Wassim Bezine

πŸ’‘πŸ’‘