DEV Community

Cover image for Five changes I made after exhausting GitHub Actions free minutes twice
MORINAGA
MORINAGA

Posted on

Five changes I made after exhausting GitHub Actions free minutes twice

The billing block hit in May. I cleared it, changed nothing structural, and it hit again in late June. The same five workflows exhausting the same 2,000-minute free tier, but faster as I'd added more daily jobs.

I could have upgraded to a paid plan. Instead I spent one session auditing where the minutes were actually going, and found the answer wasn't "the pipeline does too much" — it was "the pipeline reinstalls dependencies from scratch on every run, and fires on commits it doesn't need to care about." That's fixable without touching output frequency.

This post is the diff: what I changed across five workflow files, which changes should move the needle most, and one concurrency decision that looked simple but had a hidden trap. The savings figures are estimates from observed job durations and run frequency, not a controlled before-and-after billing measurement.

First: where the minutes were actually going

Before optimizing, I needed to understand the composition. The pipeline runs:

  • yt-publish: daily Short, ~6-8 minutes (ffmpeg + edge-tts + Playwright for OG)
  • yt-publish-longform: weekly, ~15-20 minutes (same stack plus mermaid-cli for diagram slides)
  • publish-articles: daily article drain, ~5-7 minutes (Playwright for OG images)
  • yt-analytics: daily, ~3-4 minutes
  • bluesky-queue: daily, ~2-3 minutes
  • ci: 4-parallel build on every push, ~8-10 minutes per job

The CI job was the quiet killer. Every content commit — articles, yt-queue updates, Bluesky queue refills, trends snapshots — was triggering a full 4-parallel build. Content commits happen 3-5 times per day. At 40 minutes per fire, that's 120-200 minutes daily from CI alone, on commits that touched no code.

The single CI pipeline design made sense for a project this size; the trigger scope did not.

Pip requirements files — the largest estimated saving

Every Python workflow was installing dependencies inline:

- run: pip install --quiet edge-tts Pillow
Enter fullscreen mode Exit fullscreen mode

No lockfile. No explicit pip download cache. On every run, pip may resolve and download packages again. setup-python caches pip's download cache, not an installed virtual environment, so installation still runs; the expected saving is reduced network and resolution work rather than a zero-cost restore.

The fix is two parts. First, extract each workflow's dependencies into a requirements file:

# .github/requirements-yt-publish.txt
edge-tts
Pillow
Enter fullscreen mode Exit fullscreen mode
# .github/requirements-publish-articles.txt
playwright==1.50.0
pyyaml==6.0.1
Enter fullscreen mode Exit fullscreen mode

Second, tell setup-python to cache based on that file:

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: "pip"
    cache-dependency-path: ".github/requirements-yt-publish.txt"

- name: Install Python deps
  run: pip install --quiet -r .github/requirements-yt-publish.txt
Enter fullscreen mode Exit fullscreen mode

The cache-dependency-path ties cache invalidation to the dependency file. With pinned requirements as key input, a dependency change produces a new cache key. It still does not guarantee a hit or restore installed packages.

Playwright's chromium browser is a special case — pip cache doesn't cover the browser binary download. I added a separate actions/cache@v4 step with a static key:

- name: Cache Playwright chromium
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-chromium-${{ runner.os }}-1.50.0

- name: Install Playwright browser (chromium only)
  run: python -m playwright install --with-deps chromium
Enter fullscreen mode Exit fullscreen mode

The static key means the cache is valid until the Playwright version changes. Chromium downloads ~120MB; caching it saves 3-4 minutes per run on the workflows that use it. The OG image generation and article publishing pipeline both use Playwright — those two workflows' Playwright installs were adding up.

The npm global cache for mermaid-cli

The longform video pipeline uses mermaid-cli to render .mmd diagram files into PNG slides, as I described in the mermaid and matplotlib slide pipeline. Installing it is slow:

npm install -g @mermaid-js/mermaid-cli
Enter fullscreen mode Exit fullscreen mode

@mermaid-js/mermaid-cli pulls puppeteer which downloads a bundled Chromium. On a cold runner: 8-12 minutes. Running once per week, that's 35-50 minutes monthly from one install command.

The fix needs two pieces. First, a package.json for the cache key:

// .github/mermaid-cli-package.json
{
  "dependencies": {
    "@mermaid-js/mermaid-cli": "latest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then the cache step and guarded install:

- uses: actions/setup-node@v4
  with:
    cache: "npm"
    cache-dependency-path: ".github/mermaid-cli-package.json"

- name: Cache global mermaid-cli
  uses: actions/cache@v4
  with:
    path: ~/.npm-global
    key: mermaid-cli-${{ runner.os }}-${{ hashFiles('.github/mermaid-cli-package.json') }}

- name: Install mermaid-cli (diagram slides)
  run: |
    npm config set prefix ~/.npm-global
    export PATH="$HOME/.npm-global/bin:$PATH"
    echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH"
    if ! command -v mmdc >/dev/null 2>&1; then
      npm install -g @mermaid-js/mermaid-cli
    fi
Enter fullscreen mode Exit fullscreen mode

The ~/.npm-global path isn't in actions/cache's default coverage — you have to specify it explicitly. The if ! command -v mmdc guard means a cache hit skips the install entirely. Cold: 10+ minutes. Warm: ~30 seconds.

Trigger pruning — the conceptual change

The CI workflow had a push trigger with no path restrictions:

on:
  push:
    branches: [main]
Enter fullscreen mode Exit fullscreen mode

Every commit triggered a 4-parallel build. That includes every content-generation bot commit. Adding paths-ignore was one line of YAML:

on:
  push:
    branches: [main]
    paths-ignore:
      - "content/**"
      - "docs/**"
Enter fullscreen mode Exit fullscreen mode

Content and docs changes don't affect build behavior. The four GitHub Actions patterns I've built all avoid mixing content commits with code CI for exactly this reason — they have different failure modes and different stakes.

The second trigger pruning was on publish-articles. That workflow had both a push trigger (fires when a new .md file is committed) and a daily cron:

on:
  push:
    branches: [main]
    paths:
      - "content/articles/**/*.md"
  schedule:
    - cron: "0 6 * * *"
Enter fullscreen mode Exit fullscreen mode

The cron already handles the cadence correctly. It's idempotent — articles already published to Dev.to or Hashnode are skipped on re-runs. The push trigger was adding ~17 redundant runs per week (roughly matching the article generation cadence), each installing Playwright and pnpm from scratch. I dropped the push trigger and kept only the cron.

The cron scheduling patterns post covers the case for cron-as-cadence-engine in more depth. The short version: push triggers are for reacting to code changes; content pipelines shouldn't depend on them.

Concurrency discipline — and the Bluesky exception

For three regenerable-data workflows — yt-analytics, refresh-content, and trends-fetch — I flipped cancel-in-progress from false to true:

concurrency:
  group: yt-analytics
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

These jobs fetch data and write it to the repo. If a new run starts while the old one is still running, the old run's output will be overwritten anyway. Canceling it early is just faster.

The Bluesky queue workflow stayed at cancel-in-progress: false. This is the trap I mentioned earlier.

In June I hit a duplicate-post incident caused by a different cancellation pathway: a queue runner posted a Bluesky entry but was interrupted before committing the queue update that marks it as sent. The next run re-read the same entry as unposted and posted it again.

cancel-in-progress: true creates that exact race structurally. Post happens → cancel fires before queue-file commit → next run reposts. For jobs where the side effect is idempotent or reversible, cancel-in-progress is safe. For Bluesky posts, the side effect is a public post that can't be unsent by the pipeline. The 2-3 minutes of overlap cost is less than the cost of a duplicate post.

The cron timing bugs post has a section on exactly this pattern — treating cancel-in-progress as a safe default rather than thinking about what happens if a cancel fires at each step.

What worked, what didn't, what I'd do differently

Worked well: The pip requirements files and the mermaid-cli cache together. These are pure wins with no tradeoffs — same output, less time, no new failure modes. The paths-ignore on CI was similarly clean.

Worked with caveats: The static Playwright cache key. It works until Playwright releases a version bump. When it does, the cached chromium won't match. I need to manually update the key in the YAML — which I will forget at some point. A version-aware key would be safer: playwright-chromium-${{ runner.os }}-${{ hashFiles('.github/requirements-publish-articles.txt') }}.

Didn't do well: I didn't instrument before I cut. I estimated minute consumption from first principles rather than pulling GitHub's usage reports. The estimates were directionally correct but I don't know how accurate. GitHub has per-workflow usage reporting in the billing settings — I should check it monthly, not wait until the block hits.

Would do differently from day one: Write requirements files at project start. Writing pip install edge-tts Pillow inline in YAML is fast at the time and creates months of cache miss debt. requirements.txt is three lines of work that pays back immediately.


FAQ

How many free minutes does GitHub Actions give?
2,000 Linux runner-minutes per month, per account (both free-plan personal accounts and free-plan organizations). Windows is 1x but counts double; macOS counts 10x. Linux is almost always the right default for CI. See the GitHub Actions billing docs for current limits.

Does pip caching actually restore correctly?
Yes, if you use cache-dependency-path pointing to a pinned requirements file. The cache key hashes the file; a requirements change busts the cache and re-installs. Without the path hint, the cache can match incorrectly across different dependency sets.

Which workflows should keep cancel-in-progress: false?
Any workflow that writes non-idempotent side effects: posting to external APIs, sending notifications, updating queue state that drives future runs. Map the side effects first. If any step writes something that can't be safely re-done or reverted, keep cancel-in-progress off.

Can I avoid actions/cache for Playwright?
You can use setup-python cache: pip alone, but pip doesn't cache the browser binaries that playwright install downloads. You need the separate actions/cache@v4 step pointed at ~/.cache/ms-playwright to capture the chromium download.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Related: Four GitHub Actions cron timing bugs that silently broke my daily pipelines · How I schedule three daily Bluesky posts from a JSONL queue

Top comments (0)