Skip to content

Optimize CI: concurrency, path filtering, dynamic matrix, and Windows gating #126

@leogdion

Description

@leogdion

Summary

MonthBar is the #1 CI spender across all BrightDigit repos at $39.98/month (76% of total spend). The highest-impact changes are: adding concurrency groups to cancel stacked runs (clearly happening given 3,064 Linux minutes last month), dynamic matrix gating to limit feature branches, and evaluating/cutting Windows builds ($7.07/month for a macOS menu bar app).

Current cost: $39.98/month ($19.19 Linux + $7.07 Windows + $0.79 claude-code-review.yml). macOS is self-hosted ($0). Estimated savings: $15–19/month.

Changes to make

There is no pre-made replacement workflow file for MonthBar. Read the existing .github/workflows/monthbar.yml first, then apply the patterns below.

1. Add concurrency groups (highest-impact change)

Add at the top level of the workflow (after the on: block):

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

With 3,064 Linux minutes in March, runs are clearly stacking. This single change may save $5–8/month.

2. Add paths-ignore to push and pull_request triggers

paths-ignore:
  - '**.md'
  - 'docs/**'
  - 'LICENSE'
  - '.github/ISSUE_TEMPLATE/**'

3. Add a configure job for dynamic matrix gating

Insert this job before build-ubuntu (adjust matrix values to match what's currently in monthbar.yml):

configure:
  name: Configure Matrix
  runs-on: ubuntu-latest
  outputs:
    full-matrix: ${{ steps.check.outputs.full }}
    cross-platform: ${{ steps.check.outputs.cross_platform }}
    ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }}
    ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }}
    ubuntu-type: ${{ steps.matrix.outputs.ubuntu-type }}
  steps:
    - id: check
      name: Determine matrix scope
      run: |
        FULL=false
        CROSS_PLATFORM=false
        REF="${{ github.ref }}"
        EVENT="${{ github.event_name }}"
        BASE_REF="${{ github.base_ref }}"

        if [[ "$REF" == "refs/heads/main" ]]; then
          FULL=true
        elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then
          FULL=true
        elif [[ "$EVENT" == "workflow_dispatch" ]]; then
          FULL=true
        elif [[ "$EVENT" == "pull_request" ]]; then
          if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then
            FULL=true
            CROSS_PLATFORM=true
          fi
        fi

        echo "full=$FULL" >> "$GITHUB_OUTPUT"
        echo "cross_platform=$CROSS_PLATFORM" >> "$GITHUB_OUTPUT"
        echo "Full matrix: $FULL, Cross-platform: $CROSS_PLATFORM (ref=$REF, event=$EVENT, base_ref=$BASE_REF)"

    - id: matrix
      name: Build matrix values
      run: |
        if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then
          # Use whatever OS/Swift versions are currently in the full matrix
          echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT"
          echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3","nightly":true}]' >> "$GITHUB_OUTPUT"
          echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT"
        else
          # Minimal: noble + Swift 6.2 only
          echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT"
          echo 'ubuntu-swift=[{"version":"6.2"}]' >> "$GITHUB_OUTPUT"
          echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT"
        fi

Note: Adjust the ubuntu-os, ubuntu-swift, and ubuntu-type full-matrix values to match what's currently in monthbar.yml. The minimal matrix (noble + Swift 6.2) is standard across repos.

4. Gate build-ubuntu to use dynamic matrix

Update build-ubuntu to depend on configure and use its outputs:

build-ubuntu:
  needs: configure
  strategy:
    matrix:
      os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }}
      swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }}
      type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }}

Keep all existing steps as-is; only change the needs and strategy.matrix values.

5. Gate Windows builds

MonthBar is a macOS menu bar app — evaluate whether Windows CI provides value. Two options:

Option A (recommended if Windows has no consumers): Remove the Windows job entirely.

Option B (if cross-platform validation is desired): Gate the Windows job to PRs targeting main/semver only:

build-windows:
  needs: configure
  if: ${{ needs.configure.outputs.cross-platform == 'true' }}
  # Keep a single job: windows-2025, Swift 6.2 only

Read the existing Windows job and decide which option applies. If MonthBar has no Windows/Linux package consumers (it's a menu bar app), Option A saves $7.07/month immediately.

6. Split build-macos into two jobs (if it has a broad matrix)

If build-macos currently includes macOS, tvOS, visionOS, watchOS, and multiple Xcode versions, split it:

  • build-macos — always runs: SPM + iOS + watchOS (self-hosted, $0)
  • build-macos-platforms — full-matrix only (needs.configure.outputs.full-matrix == 'true'): macOS, tvOS, visionOS, older Xcode versions

If the macOS matrix is already minimal, skip this split.

7. Update the lint job

Change the if condition to:

if: ${{ !cancelled() && !failure() }}

And add any new jobs (build-macos-platforms, build-windows if kept) to the needs list.

8. Add .github/workflows/cleanup-caches.yml

Create this new file:

name: Cleanup Branch Caches
on:
  delete:

jobs:
  cleanup:
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Cleanup caches for deleted branch
        uses: actions/github-script@v7
        with:
          script: |
            const ref = `refs/heads/${context.payload.ref}`;
            const caches = await github.rest.actions.getActionsCacheList({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: ref,
            });
            for (const cache of caches.data.actions_caches) {
              console.log(`Deleting cache: ${cache.key}`);
              await github.rest.actions.deleteActionsCacheById({
                owner: context.repo.owner,
                repo: context.repo.repo,
                cache_id: cache.id,
              });
            }
            console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`);

9. Add .github/workflows/cleanup-pr-caches.yml

Create this new file:

name: Cleanup PR Caches

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Cleanup caches for closed PR
        uses: actions/github-script@v7
        with:
          script: |
            const pr = context.payload.pull_request.number;
            const refs = [
              `refs/pull/${pr}/merge`,
              `refs/pull/${pr}/head`,
            ];
            for (const ref of refs) {
              const caches = await github.rest.actions.getActionsCacheList({
                owner: context.repo.owner,
                repo: context.repo.repo,
                ref,
              });
              for (const cache of caches.data.actions_caches) {
                console.log(`Deleting cache: ${cache.key} (${ref})`);
                await github.rest.actions.deleteActionsCacheById({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  cache_id: cache.id,
                });
              }
              console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`);
            }

Impact

  • Concurrency: cancels stacked runs — likely the single biggest saving given 3,064 Linux minutes last month
  • Dynamic matrix: feature branch pushes drop from ~15+ jobs to ~3–4
  • Windows removal/gating: $7.07/month eliminated or reduced to PRs only
  • Estimated total savings: $15–19/month (37–47% reduction)

Checklist

  • Read .github/workflows/monthbar.yml to understand the current job structure
  • Add concurrency block at the top level
  • Add paths-ignore to push and pull_request triggers
  • Add the configure job (adjust full-matrix values to match current Ubuntu matrix)
  • Update build-ubuntu to use needs: configure and dynamic matrix outputs
  • Decide on Windows: remove entirely (Option A) or gate to cross-platform PRs (Option B)
  • Split build-macos if the current macOS matrix is broad
  • Update lint job condition and needs
  • Create .github/workflows/cleanup-caches.yml with the content above
  • Create .github/workflows/cleanup-pr-caches.yml with the content above
  • Push to a feature branch and verify minimal jobs run
  • Open a PR to main and verify full matrix fires

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions