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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions